Skip to main content
Version: V3

Schema mapping reference

The schema mapping section of a sync project's config.yml defines how data translates between the source system and the destination system. Worked examples for NetBox → Infrahub and Nautobot → Infrahub appear at the end.

For the full configuration schema (not just the mapping section), see Sync instance configuration.

Anatomy of a schema mapping​

Each entry in the schema_mapping section maps one model from the source system to one model in the destination system. A mapping entry contains:

  • Name — the destination model name.
  • Mapping — the source model name in the source system.
  • Identifiers — the field(s) that uniquely identify an object in this model.
  • Fields — the per-field mappings between source and destination.

A minimal example:

schema_mapping:
- name: Device
mapping: dcim.device
identifiers: ["name"]
fields:
- name: name
mapping: name
- name: serial
mapping: serial
- name: status
mapping: status.value

This says: for each dcim.device in the source system, create or update a Device object in the destination using the name field as the unique identifier, and map the name, serial, and status.value fields directly.

Field mappings​

Each entry in the fields list defines one field mapping. The most common patterns are direct mapping, nested attribute mapping, static values, and references to other models.

Direct field mapping​

The source field name maps directly to the destination field name:

- name: name
mapping: name

This is the most common case. The destination field name gets the value of the source field name.

Renaming fields​

When the destination field has a different name than the source field, list them separately:

- name: device_role
mapping: role.slug

The destination field device_role gets the value of the source's role.slug.

Nested attribute mapping​

For source data with nested structure, use dotted notation to reach into the nested attribute:

- name: status
mapping: status.value
- name: site
mapping: site.slug
- name: primary_ip
mapping: primary_ip4.address

Static values​

When a destination field should always be set to the same value regardless of source data, use static:

- name: source_system
static: netbox

This is useful for fields that record provenance, type discriminators, or any field where the value is determined by the sync configuration rather than the source data.

static assigns whatever value you declare, including false, 0, "" and []:

- name: full_depth
static: false

Omitting static, or setting it to null, means "no static value" — the field then falls back to its mapping or reference. A field that declares both static and mapping takes the static value.

Not every adapter assigns these values yet. The NetBox, Nautobot and Prometheus adapters preserve false, 0, "" and []; the ACI, Generic REST API, IP Fabric and Slurp’it adapters, and the custom-adapter example, currently drop them and emit no field at all. That is a known bug in those adapters, not a difference in intended behavior — static is meant to assign any declared value on every adapter.

References to other models​

When a destination field is a relationship to another model that is also being synced, use reference to point to that model:

- name: device
mapping: device.name
reference: Device

This says: the value of the source's device.name field should resolve to the Device object with that name in the destination. The referenced model must be synced before this one; infrahub-sync computes that ordering automatically from reference fields, or you can override it with the optional order key in the project configuration.

Identifiers​

The identifiers list defines which field(s) uniquely identify an object of this model. Identifiers are used to determine whether an object already exists in the destination and should be updated, or whether a new object should be created.

Single-field identifiers​

For most models, a single field — typically name — is enough:

identifiers: ["name"]

Composite identifiers​

Some models need multiple fields to be uniquely identified. For example, Infrahub identifies an IP address by the address plus the IP namespace it belongs to:

identifiers: ["address", "ip_namespace"]

All listed fields must be present and unique together. Each identifier field must also be mapped in the fields list.

Pick the identifiers your destination actually uses, not the ones the source happens to have. Infrahub keys an address by address plus namespace, while NetBox groups addresses by VRF without requiring them to be unique inside one, so the shipped examples/netbox_to_infrahub/ mapping turns each VRF name into an IP namespace and identifies on that. That separation lasts only while the VRF names differ: two VRFs sharing a name produce the same namespace record twice and the run fails while loading the source, and an address in a VRF named default collides with the same address in no VRF at all. To key the namespace on something NetBox cannot repeat, see Key the namespace on the VRF ID instead of its name.

Filters​

Filters control which objects from the source get synced. Apply them at the model level to include or exclude specific objects.

- name: Device
mapping: dcim.device
identifiers: ["name"]
filters:
- field: status.value
operation: "=="
value: active
fields:
- name: name
mapping: name

This syncs only devices with status.value == "active". Other devices are skipped.

Available filter operations​

Infrahub Sync supports the following 14 filter operations:

OperationDescription
==Equal to the value
!=Not equal to the value
>Greater than (numeric)
<Less than (numeric)
>=Greater than or equal (numeric)
<=Less than or equal (numeric)
inValue is in a list or string
not inValue is not in a list or string
containsField value contains the given value
not containsField value does not contain the given value
is_emptyField is None or empty (no value argument needed)
is_not_emptyField is not None and not empty (no value argument needed)
regexField matches the regular expression in value
is_ip_withinField (an IP address) is within the IP range in value

Multiple filters on the same model are combined with AND — an object must match all filters to be included.

Transforms and custom Jinja filters​

For cases where a field value needs to be transformed during the mapping — uppercase a string, parse a date, compute a derived value — apply a transforms entry to the mapping. The transform takes a field (the target field name) and an expression (a Jinja-compatible expression evaluated against the source object).

- name: Device
mapping: dcim.device
identifiers: ["name"]
fields:
- name: name
mapping: name
transforms:
- field: name
expression: "{{ name | upper }}"

Adapters can also register custom Jinja filters via a _add_custom_filters class method on the adapter model. The ACI adapter, for example, includes an aci_device_name filter for resolving ACI node IDs to device names. See Local Adapters for the implementation pattern, and Sync instance configuration for the full transforms syntax.

Validating a mapping​

Register and validate the package before creating a plan:

infrahub-sync configs validate <config-id> <version>
infrahub-sync diff --config-id <config-id> --version <version> --reason "review mapping"

The diff prints every proposed create, update, and delete. Review it model by model:

  • Are the field values being mapped correctly?
  • Are references resolving to the right objects in the destination?
  • Are filters excluding the right objects?
  • Are there unexpected creates or deletes?

The same run also saves a plan artifact you can review on its own, per operation and per kind:

infrahub-sync runs plan <run-id> --detail

A plan records deletes but apply never executes one — it applies the non-deletes, completes successfully, and reports how many deletes it skipped. Deletes are also only computed when the destination side ran a full extract; both review depths say which of the two happened. See Create and review a plan.

Adjust the mapping and re-run the diff until it matches expectations. Only then run the sync.


Two common source systems — NetBox and Nautobot — illustrate how the syntax above plays out:

Common patterns for NetBox → Infrahub​

The patterns below cover the most common NetBox → Infrahub mappings. Adapt the destination field names to match your Infrahub schema.

Sites and locations​

- name: Location
mapping: dcim.site
identifiers: ["name"]
fields:
- name: name
mapping: name
- name: slug
mapping: slug
- name: description
mapping: description
- name: status
mapping: status.value

Devices with role and platform references​

- name: Device
mapping: dcim.device
identifiers: ["name"]
fields:
- name: name
mapping: name
- name: serial
mapping: serial
- name: location
mapping: site.slug
reference: Location
- name: role
mapping: role.slug
reference: DeviceRole
- name: platform
mapping: platform.slug
reference: Platform

Interfaces with device reference​

- name: Interface
mapping: dcim.interface
identifiers: ["name", "device"]
fields:
- name: name
mapping: name
- name: device
mapping: device.name
reference: Device
- name: enabled
mapping: enabled
- name: type
mapping: type.value

IP addresses with namespace and VRF references​

The namespace is what identifies the address; the VRF is kept as an ordinary relationship. namespace_name is produced by a transform that reads the source VRF name and falls back to Infrahub's built-in default namespace when there is no VRF — see the shipped examples/netbox_to_infrahub/config.yml for the exact expression.

Because the namespace is keyed by that name, this mapping cannot carry two VRFs that share one. The namespace records come from the same VRF list, so the second one raises ObjectAlreadyExists and the run stops while loading the source — before a plan exists and before anything is written at the destination. It does not merge the two. A VRF named default is a different case: its objects share the built-in namespace with every object that has no VRF, and any prefix or address value repeated inside that shared namespace collides there. Key the namespace on the VRF ID instead of its name avoids both without touching NetBox. Adding either mapping to an Infrahub that already holds imported data does not move the objects a previous sync created; see Migrate from NetBox or Nautobot for how to handle that.

- name: IPAddress
mapping: ipam.ip-address
identifiers: ["address", "ip_namespace"]
fields:
- name: address
mapping: address
- name: ip_namespace
mapping: namespace_name
reference: IPNamespace
- name: vrf
mapping: vrf.name
reference: VRF
- name: status
mapping: status.value
- name: description
mapping: description

Key the namespace on the VRF ID instead of its name​

A name-based namespace refuses a source that repeats a VRF name. NetBox IDs cannot repeat, so deriving the namespace from id keeps same-name VRFs apart without renaming anything in NetBox. These three entries are the whole recipe — they replace the IpamNamespace, IpamPrefix and IpamIPAddress entries of examples/netbox_to_infrahub/config.yml, and the scope note below says what they do and do not cover:

- name: IpamNamespace
mapping: ipam.vrfs
identifiers: ["name"]
fields:
- name: name
mapping: namespace_name
- name: description
mapping: description
transforms:
- field: namespace_name
expression: '{{ "%r" % ("netbox-vrf-%s" % id) }}'

- name: IpamPrefix
mapping: ipam.prefixes
identifiers: ["prefix", "ip_namespace"]
fields:
- name: prefix
mapping: prefix
- name: description
mapping: description
- name: status
mapping: resolved_status
- name: member_type
mapping: resolved_member_type
- name: ip_namespace
mapping: namespace_name
reference: IpamNamespace
transforms:
- field: resolved_status
expression: "{{ 'active' if status.value == 'container' else status.value }}"
- field: resolved_member_type
expression: "{{ 'prefix' if status.value == 'container' else 'address' }}"
- field: namespace_name
expression: '{{ "%r" % ("netbox-vrf-%s" % vrf.id if vrf is defined and vrf else "default") }}'

- name: IpamIPAddress
mapping: ipam.ip-addresses
identifiers: ["address", "ip_namespace"]
fields:
- name: address
mapping: address
- name: description
mapping: description
- name: status
mapping: status.value
- name: ip_namespace
mapping: namespace_name
reference: IpamNamespace
transforms:
- field: namespace_name
expression: '{{ "%r" % ("netbox-vrf-%s" % vrf.id if vrf is defined and vrf else "default") }}'

The namespace record and every prefix and address pointing at it derive the same value, so a prefix in NetBox VRF 3 lands in netbox-vrf-3 whatever that VRF is called, and an overlapping prefix in VRF 4 lands in netbox-vrf-4. Objects with no VRF — a null vrf or no vrf key at all — still go to Infrahub's built-in default namespace.

A VRF literally named default behaves differently here than under the shipped mapping: it is assigned its own netbox-vrf-<id> namespace rather than sharing the built-in default, so its objects no longer share a namespace with objects that have no VRF.

This recipe covers namespaces, prefixes and addresses only. It drops the vrf reference those two kinds carry in the full example, and it changes nothing about IpamVRF, which the full example still loads keyed on the VRF name. The full example therefore still refuses two same-name VRFs, at that entry instead of at the namespace: copying only the namespace transform into it is not enough, and this recipe makes no claim about the rest of that inventory. To run the full example over such a source you also need a name transform of your own on IpamVRF.

Changing what an object is identified by is not by itself a destination-uniqueness fix. Two prefixes or two addresses that are genuinely the same value inside one namespace still refuse, under either recipe. What the derived namespace buys you is that a repeated VRF name stops being what makes them collide.

The derived name is only as stable as the NetBox ID behind it, and this recipe assumes a single source instance. Recreating a VRF gives it a new ID, and so a new namespace; a restore that preserves IDs preserves the namespace identity with them. IDs are local to one NetBox instance, so a second instance can reuse the same IDs for unrelated VRFs: those would collide in one namespace rather than land in separate ones. Combining instances needs a source discriminator applied consistently to the namespace, prefix and address expressions, together with a deliberate mapping and migration — this recipe does not cover that. Moving between the two recipes changes the identity as well. None of this renames data an earlier sync already wrote; see Migrate from NetBox or Nautobot for what adopting a new namespace identity actually takes.

Filtering by tenant or tag​

- name: Device
mapping: dcim.device
identifiers: ["name"]
filters:
- field: tenant.slug
operation: "=="
value: production
fields:
- name: name
mapping: name

Common patterns for Nautobot → Infrahub​

Nautobot's API and model structure are similar to NetBox but with some differences in field names and nested structure. The mapping patterns above largely apply; the main differences:

  • Nautobot uses display instead of name in some places.
  • Nautobot status fields are objects rather than strings — use status.name instead of status.value.
  • Custom fields and computed fields appear in different sections of the API response.

Start from the examples/nautobot_to_infrahub/ directory in the Infrahub Sync repository and adapt from there.