Skip to content

The .qbak archive format

  • PostgreSQL
  • MySQL
  • MariaDB
  • MongoDB
  • Redis / Valkey
  • CLI

A .qbak file is what Querybara’s backups write by default: the desktop app’s Backup wizard, its scheduled backups and querybara backup all produce it. It holds one entry per object (DDL, rows, documents or keys) and a manifest that describes them, so a restore can pick objects without reading the rest. An archive can be encrypted with a passphrase; then every byte after the header is authenticated, and any change to the file is detected.

This page describes version 1 of the format. It is the contract: a reader written from it opens every archive Querybara writes. The reference implementation is in packages/backup/src/archive/ of the Querybara source.

All integers are unsigned and big-endian. A file is a header, the entries, the manifest and a trailer:

header magic | u16 version | u16 flags | u32 n | n bytes of JSON | [32-byte MAC]
entry 0 entry header | frame | frame | ... (the last frame has the LAST bit)
entry 1 ...
manifest an entry with index 0xFFFFFFFF, holding the manifest JSON
trailer "QBAKEND\0" | u64 manifest offset | u64 manifest length | u32 0 | u32 CRC-32
Bytes Field
0–7 Magic 51 42 41 4B 0D 0A 1A 0A (QBAK\r\n, Ctrl-Z, \n)
8–9 Format version: 1
10–11 Flags: 0x1 encrypted, 0x2 compressed; every other bit is 0
12–15 n, the length of the header JSON (at most 64 KiB)
16– The header JSON, UTF-8
then Encrypted archives only: HMAC-SHA256 of bytes 0 to the end of the JSON

The header JSON is {"compression": "gzip" | "none"}. An encrypted archive adds "cipher": "aes-256-gcm" and "kdf": {"name": "scrypt", "log2N", "r", "p", "salt"}, where the salt is 16 random bytes in base64. The flags and the JSON must agree.

A reader refuses other versions and other ciphers. It also refuses scrypt costs outside log2N 10–20, r 1–32, p 1–16 and 1 GiB of memory, so a crafted header cannot make it spend hours or gigabytes.

An entry is a 16-byte entry header followed by frames:

Bytes Field
0–3 51 45 4E 54 (QENT)
4–7 The entry’s index: 0, 1, 2… in file order; 0xFFFFFFFF is the manifest
8–15 Nonce prefix: 8 random bytes when encrypted, zeros when plain

Each frame starts with a u32 word: bit 31 is set on the entry’s last frame, and bits 0–30 hold the payload length. Then:

  • in a plain archive, the payload, then a CRC-32 of the word and the payload;
  • in an encrypted archive, the AES-256-GCM ciphertext of the payload (the same length), then its 16-byte tag.

Writers use 64 KiB payloads; readers refuse frames over 16 MiB. Every entry has at least one frame: an empty entry is one empty last frame.

Joined together, the payloads are the entry’s stored content. With the compressed flag, that is a gzip stream of the content, each entry compressed on its own; otherwise it is the content itself.

The passphrase, normalised to Unicode NFC, and the header salt go through scrypt with the header’s cost, giving 64 bytes. The first 32 are the AES-256-GCM key, the last 32 the HMAC key. The header MAC tells a wrong passphrase apart from a damaged file before any entry is read. Querybara writes log2N 17, r 8, p 1. The passphrase is not stored anywhere.

Frames follow the STREAM construction. Frame i, counting from 0 in each entry, is sealed with:

  • nonce: the entry’s 8-byte prefix followed by u32 i (12 bytes);
  • associated data: the 16-byte entry header, u32 i, and one byte: 1 on the last frame, 0 otherwise.

A frame therefore cannot be changed, reordered, moved to another entry or index, or dropped from the end of an entry without the tag check failing. Nonce prefixes are random per entry, so nonces never repeat under one key.

The last 32 bytes of the file are the trailer. Its CRC-32 covers its first 28 bytes. The manifest offset plus the manifest length plus 32 must equal the file size.

The manifest entry at that offset is framed, compressed and encrypted like the other entries, and holds UTF-8 JSON:

{
"format": "querybara-backup",
"formatVersion": 1,
"createdAt": "2026-09-29T12:00:00.000Z",
"producer": "Querybara",
"engine": "postgres",
"serverVersion": "16.4",
"database": "shop",
"databaseOptions": { "encoding": "UTF8" },
"options": { "snapshot": "repeatable-read", "compression": "gzip", "encrypted": true },
"objects": [
{
"id": "table:public.orders:create",
"kind": "table",
"schema": "public",
"name": "orders",
"qualifiedName": "public.orders",
"dependsOn": ["schema:public:create"],
"ddl": "ddl/0003-table-public.orders.json",
"data": { "entry": "data/0003-public.orders.sql", "count": 1200, "columns": ["id", "total"] }
}
],
"entries": [
{
"name": "ddl/0003-table-public.orders.json",
"index": 4,
"offset": 5120,
"storedLength": 311,
"size": 402,
"sha256": "…64 hex digits…",
"contentType": "application/json"
}
],
"warnings": []
}

objects are in creation order. kind is one of schema, extension, type, sequence, table, partition, index, unique, check, primary-key, column, foreign-key, trigger, view, materialized-view, routine, event, grants, collection or keys.

dependsOn lists the ids that must exist first. parent names the table that an attached object, such as a foreign key or a trigger, belongs to. A selective restore takes the chosen objects, what they depend on, and what is attached to them; a foreign key comes only with both of its tables.

entries locate every entry except the manifest:

  • offset is the position of the entry header;
  • storedLength is the length of the entry header and its frames;
  • size and sha256 (lower-case hex) describe the uncompressed content. A reader checks both after reading an entry.

contentType is application/sql, application/json, application/bson or application/x-ndjson.

ddl/NNNN-<kind>-<name>.json holds one object’s statements: {"pre": [...], "data": [...], "post": [...], "grants": [...]}.

  • pre creates the object.
  • data runs after the rows, for example sequence positions and materialized view refreshes.
  • post runs after every table is loaded: foreign keys, triggers and events.
  • grants is optional.

data/NNNN-<name>.sql holds a table’s rows as multi-row INSERT statements, each ending with ; and a newline.

A restore runs every pre, then the rows, then every data, then post, then grants.

meta/NNNN-<name>.json is {"name", "type", "options", "indexes"}, with the listCollections options (validator, collation, capped, time series and so on) and each index specification as canonical Extended JSON.

data/NNNN-<name>.bson holds concatenated BSON documents. With Extended JSON chosen instead, the entry is .jsonl, one canonical Extended JSON document per line.

keys/<database>.bson holds concatenated BSON documents, one per key:

Field Value
k The key, binary
v The DUMP payload, binary
t int64 PTTL in milliseconds, -1 for none
x int64 expiry in Unix milliseconds, or null

A restore uses RESTORE with the remaining TTL, or with ABSTTL and x when asked to keep the original expiry (querybara restore --keep-expiry).

A version 1 reader refuses archives with another version in the header or the manifest. New optional manifest fields, new detail and options values, and new header JSON fields can be added without a version change; readers ignore what they do not know. Anything that changes the byte layout, the cipher construction, the list of object kinds or the meaning of an existing field needs version 2.

Documents Querybara 0.1.1 · built frombc9f5aa