---
title: "The catalog is the domain XML saved with the snapshot"
description: "The catalog is the inactive domain XML saved in the same moment as the disk snapshot. ZFS can roll the blocks back and still not know the NICs, firmware, or boot order. A restore is a clone plus virsh define of that file, with the disk paths rewritten."
url: https://howtobackupvm.com/en/blog/domain-xml-is-the-catalog/
---

- Home

- Guides

- The catalog is the domain XML saved with the snapshot

# The catalog is the domain XML saved with the snapshot

The catalog is the inactive domain XML saved in the same moment as the disk snapshot. ZFS can roll the blocks back and still not know the NICs, firmware, or boot order. A restore is a clone plus virsh define of that file, with the disk paths rewritten.

Published 2026-11-09· HowToBackupVM editors

The catalog is the inactive domain XML you saved when you took the snapshot. ZFS remembers the dataset and the snapshot name. It does not remember the bridge, the firmware, the boot order, or the guest agent channel. Without that file, you can clone the disks and still not start the same guest.

A restore is a clone plus virsh define of a rewritten copy of that file. Instant recovery is a clone you can promote. Do not edit the file you saved. It has to keep describing the snapshot, not the clone you made later.

On libvirt, this file is the catalog. Proxmox stores the equivalent in /etc/pve/qemu-server/<vmid>.conf, and VMware stores it in the vmx. Save that file with the copy, or the blocks come back and the guest does not. See back up Proxmox virtual machines and back up VMware virtual machines.

## What do you save with the snapshot?

Run virsh dumpxml --inactive <domain> in the same window as the snapshot. --inactive is the persistent definition, which is what virsh define expects. A live dump describes the running guest, including addresses assigned after start. Do not pass --security-info unless you mean to store those secrets in the catalog file.

The file has to name every disk you snapshotted. A disk hotplugged and never written into the persistent config is in the zvol snapshot and missing from --inactive. Compare virsh domblklist <domain> --inactive with the datasets you just snapshotted. If one source is absent, do not call the point done.

Keep the target device, the bus, and the boot order with each source. The guest boots from vda or sda, not from the path string. Also keep the interface model and MAC, the bridge, the machine type, the firmware loader, the nvram path, and the guest agent channel. A Windows guest defined as SCSI with an emulated NIC does not become a virtio guest because the file you saved looks tidier.

The nvram file is not inside the XML. It is the UEFI variable store the <nvram> element points at. Copy that file, or snapshot the dataset that holds it, before you call the point done. A disk from 09:00 and firmware variables from yesterday are not a 09:00 guest. A guest that stops at the firmware shell can be a missing vars file.

The snapshot is still crash-consistent unless you froze the guest first. Make application-consistent virtual machine backups.

## Where does the file live?

Do not put the XML in the zvol. A zvol snapshot is the disk blocks. The catalog is a separate file.

Put it on a sibling dataset, one per guest, such as tank/vm-db-catalog/2026-11-09-0900.xml, and snapshot that dataset at the same time. One dataset per VM is the job, the quota, and the key. Send that dataset with the disk. A replica that received only the zvol has the blocks and nothing to define.

Do not store the XML body in a ZFS user property. Property values are limited to 8192 bytes, and a domain with many disks can be larger than that. Store the file, and store only its sha256 in the property.

zfs send -p includes properties. -R implies -p. Without it, a checksum set on the source does not arrive. On an encrypted dataset, -p also has to be a raw send. A raw send replicates ciphertext and leaves the key at home. The file still has to be in a dataset you sent. Keep an off-site virtual machine backup.

## How do you rewrite the copy?

Clone the disk datasets first, and read the clone path back. Then change a copy of the saved XML.

Change the name to one that is not defined. Change the UUID. libvirt will not define a second domain with the production UUID while the original is still defined. Leave production defined. If define fails on that UUID, the copy still has the original, and undefining production to get past the error removes the guest you still need.

Change every disk source to the clone path. Match by the old source path and keep the leaf name, so disk0 stays disk0. The saved element <source dev='/dev/zvol/tank/vm-db/disk0'/> becomes <source dev='/dev/zvol/tank/vm-db-recover/disk0'/>. Leave <target dev>, the bus, and the boot order alone. Changing sda to vda because the new path looked like a zvol is how a guest with no virtio driver loses the disk it was installed on. Leave the machine type, the loader, and the NIC model alone for the same reason.

Point <nvram> at the copy you snapped, not at the production vars file. Two domains on one vars file share boot entries, and a test boot can change production’s firmware state.

Put each interface on a bridge with no uplink, and change the MAC, before this domain can share a segment with production. A cleanroom is a clone on a bridge with no uplink. If the guest agent channel source path contains the production domain name, change that path too. If two domains bind the same socket path, the second start fails.

virsh define the copy. Define does not start the guest. Do not run virsh start until a dump of the new name shows the clone paths and the isolated bridge. Disconnecting the NIC after the guest has started is late. Test a virtual machine restore.

## What fails?

You dumped the XML the next morning. A NIC, a disk, or the firmware no longer matches the snapshot, so the guest that starts is not the one you meant.

You saved a live dump. virsh define rejected it, or it defined and a disk source is a runtime path that is gone after the host reboots.

A hotplugged disk was in the snapshot and not in the inactive XML. The clone has those blocks, and the domain never opens them.

You stored the XML body in the user property. The limit is 8192 bytes. If the file is larger, that property is not a catalog the replica can define.

You pointed the new domain at the production zvol. Define succeeds. Start opens the live disk, or two domains open one zvol.

The nvram path still names the production file. The test guest and production share firmware variables, or the test guest has no vars file and stops at the firmware shell.

The catalog file is not a copy of the disk. A snapshot is not a backup. A file that exists only on the production pool dies with that pool.

## Check the file before you define

The catalog file’s sha256 matches the property recorded with that snapshot. Every snapshotted disk source is in the file, and the file parses.

After the rewrite, and before start, dump the new name. No production disk path remains. The UUID and the name differ. The bridge is the isolated bridge. The nvram path is the copy. The disk targets and boot order match the saved file. If define exited 0 and the dump still shows the production zvol, do not start.

After start, on that bridge, the guest answers. A record written before the snapshot’s creation time is present. A record written on production after that time is absent. If the later record is there, you started production, or you cloned a later snapshot.

The script builds its own fixtures. It does not run virsh, create a pool, or start a guest. It proves that a catalog missing a snapshotted disk is rejected, that this fixture XML does not fit in an 8192-byte property, and that the rewrite removes production paths without changing disk targets, bus, or boot order. It does not prove libvirt accepted the XML, or that a guest booted. The vdNN targets in the fixture are not libvirt device names.

```text
#!/usr/bin/env bash
set -euo pipefail

root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT

python3 - "$root" <<'PY'
import hashlib
import sys
import uuid
from pathlib import Path
import xml.etree.ElementTree as ET

root = Path(sys.argv[1])
prop_limit = 8192
prod_name = "db"
prod_uuid = "6f1e5c2a-1111-4222-8333-444455556666"
prod_bridge = "br0"
prod_mac = "52:54:00:11:22:33"
prod_nvram = "/var/lib/libvirt/qemu/nvram/db_VARS.fd"
prod_channel = (
    "/var/lib/libvirt/qemu/channel/target/domain-1-db/org.qemu.guest_agent.0"
)
loader = "/usr/share/OVMF/OVMF_CODE_4M.fd"
snap = "2026-11-09-0900"
prod_prefix = "/dev/zvol/tank/vm-db/"
clone_prefix = "/dev/zvol/tank/vm-db-recover/"

disks = [f"{prod_prefix}disk{i}" for i in range(48)]


def build(disk_paths):
    domain = ET.Element("domain", {"type": "kvm"})
    ET.SubElement(domain, "name").text = prod_name
    ET.SubElement(domain, "uuid").text = prod_uuid
    os_el = ET.SubElement(domain, "os", {"firmware": "efi"})
    typ = ET.SubElement(os_el, "type", {"arch": "x86_64", "machine": "pc-q35-9.2"})
    typ.text = "hvm"
    ET.SubElement(os_el, "loader", {"readonly": "yes", "type": "pflash"}).text = loader
    ET.SubElement(
        os_el, "nvram", {"template": "/usr/share/OVMF/OVMF_VARS_4M.fd"}
    ).text = prod_nvram
    devices = ET.SubElement(domain, "devices")
    for n, path in enumerate(disk_paths):
        disk = ET.SubElement(devices, "disk", {"type": "block", "device": "disk"})
        ET.SubElement(disk, "driver", {"name": "qemu", "type": "raw"})
        ET.SubElement(disk, "source", {"dev": path})
        ET.SubElement(disk, "target", {"dev": f"vd{n:02d}", "bus": "virtio"})
        ET.SubElement(disk, "boot", {"order": str(n + 1)})
    iface = ET.SubElement(devices, "interface", {"type": "bridge"})
    ET.SubElement(iface, "mac", {"address": prod_mac})
    ET.SubElement(iface, "source", {"bridge": prod_bridge})
    ET.SubElement(iface, "model", {"type": "virtio"})
    channel = ET.SubElement(devices, "channel", {"type": "unix"})
    ET.SubElement(channel, "source", {"mode": "bind", "path": prod_channel})
    ET.SubElement(
        channel, "target", {"type": "virtio", "name": "org.qemu.guest_agent.0"}
    )
    return domain


def disk_sources(domain):
    found = []
    for src in domain.findall("./devices/disk/source"):
        path = src.get("dev") or src.get("file")
        if not path:
            raise SystemExit("disk source has neither dev nor file")
        found.append(path)
    return found


def missing_disks(xml_bytes, expected):
    domain = ET.fromstring(xml_bytes)
    found = disk_sources(domain)
    missing = [path for path in expected if path not in found]
    extra = [path for path in found if path not in expected]
    return missing, extra


incomplete = ET.tostring(build(disks[:-1]))
missing, extra = missing_disks(incomplete, disks)
if not missing or extra:
    raise SystemExit("incomplete catalog was accepted")

domain = build(disks)
xml_bytes = ET.tostring(domain, encoding="utf-8", xml_declaration=True)
if len(xml_bytes) <= prop_limit:
    raise SystemExit(f"fixture XML is {len(xml_bytes)} bytes, not over {prop_limit}")

catalog = root / "tank" / "vm-db-catalog" / f"{snap}.xml"
catalog.parent.mkdir(parents=True)
catalog.write_bytes(xml_bytes)
if str(catalog).startswith("/dev/zvol"):
    raise SystemExit("catalog file landed under /dev/zvol")

digest = hashlib.sha256(catalog.read_bytes()).hexdigest()
prop = f"sha256={digest}"
if len(prop.encode()) > prop_limit:
    raise SystemExit("checksum property exceeds 8192 bytes")
if "<domain" in prop:
    raise SystemExit("property holds XML")
(root / "property").write_text(prop + "\n", encoding="utf-8")

missing, extra = missing_disks(catalog.read_bytes(), disks)
if missing or extra:
    raise SystemExit("saved catalog does not match the snapshotted disks")

new_name = "db-recover"
new_uuid = str(uuid.uuid4())
new_mac = "52:54:00:aa:bb:cc"
new_bridge = "br-isolated"
new_nvram = "/var/lib/libvirt/qemu/nvram/db-recover_VARS.fd"
new_channel = (
    "/var/lib/libvirt/qemu/channel/target/domain-db-recover/org.qemu.guest_agent.0"
)

restored = build(disks)
targets_before = [
    (
        disk.find("target").get("dev"),
        disk.find("target").get("bus"),
        disk.find("boot").get("order"),
    )
    for disk in restored.findall("./devices/disk")
]
restored.find("name").text = new_name
restored.find("uuid").text = new_uuid
restored.find("./os/nvram").text = new_nvram
restored.find("./devices/interface/mac").set("address", new_mac)
restored.find("./devices/interface/source").set("bridge", new_bridge)
restored.find("./devices/channel/source").set("path", new_channel)
for src in restored.findall("./devices/disk/source"):
    prod = src.get("dev")
    if not prod.startswith(prod_prefix):
        raise SystemExit(f"unexpected source {prod}")
    src.set("dev", clone_prefix + prod.rsplit("/", 1)[-1])

targets_after = [
    (
        disk.find("target").get("dev"),
        disk.find("target").get("bus"),
        disk.find("boot").get("order"),
    )
    for disk in restored.findall("./devices/disk")
]
if targets_before != targets_after:
    raise SystemExit("rewrite changed disk target, bus, or boot order")
if restored.find("./os/type").get("machine") != "pc-q35-9.2":
    raise SystemExit("machine type changed")
if restored.find("./os/loader").text != loader:
    raise SystemExit("loader changed")
if restored.find("./devices/interface/model").get("type") != "virtio":
    raise SystemExit("NIC model changed")

out = ET.tostring(restored, encoding="utf-8", xml_declaration=True)
defined = root / "defined.xml"
defined.write_bytes(out)
again = ET.fromstring(defined.read_bytes())
if again.findtext("name") != new_name or again.findtext("uuid") != new_uuid:
    raise SystemExit("name or uuid was not rewritten")
if again.findtext("uuid") == prod_uuid or again.findtext("name") == prod_name:
    raise SystemExit("production name or uuid remains")
for path in disk_sources(again):
    if path.startswith(prod_prefix):
        raise SystemExit(f"production disk remains: {path}")
    if not path.startswith(clone_prefix):
        raise SystemExit(f"disk is not on the clone: {path}")
if again.find("./devices/interface/source").get("bridge") != new_bridge:
    raise SystemExit("bridge was not changed")
if again.find("./devices/interface/mac").get("address") == prod_mac:
    raise SystemExit("MAC is still the production MAC")
if again.findtext("./os/nvram") == prod_nvram:
    raise SystemExit("nvram still points at production")
text = ET.tostring(again, encoding="unicode")
if prod_channel in text or prod_nvram in text:
    raise SystemExit("production channel or nvram path remains")
if prod_bridge in text:
    raise SystemExit("production bridge name remains")

print(f"catalog_bytes={len(xml_bytes)}")
print(f"property_bytes={len(prop.encode())}")
print(f"disks={len(disks)}")
print("ok")
PY
```

## Questions

The inactive domain XML from that moment: disk sources, target devices, bridges, boot order, firmware, the nvram path, and the guest agent channel. A restore is a clone plus virsh define of that file with the disk paths rewritten.

No. OpenZFS limits a user property value to 8192 bytes, and a domain XML can be larger than that. Store the file on a sibling dataset you also send, and store only the sha256 in the property.

No. Use virsh dumpxml --inactive at snapshot time. That is the persistent definition. A live dump includes runtime addresses, and it can still omit a hotplugged disk that was never saved into the persistent config. Every disk you snapshotted has to be in the file.

After virsh define, dump the new name. The disk sources are the clone paths, the UUID is not the production UUID, and the bridge has no uplink. Then boot on that bridge and find a record written before the snapshot. Exit 0 from define is not that reading.

## Sitemap

- [HTML](https://howtobackupvm.com/en/blog/domain-xml-is-the-catalog/): the page this markdown mirrors
- [llms.txt](https://howtobackupvm.com/llms.txt): curated index of markdown pages
- [Sitemap](https://howtobackupvm.com/sitemap.md): every public page
