Skip to content

# Natural Keys in Nautobot Models

Added in version 2.0.0

Nautobot models derived from `BaseModel` automatically support the following natural key APIs:

- Django's `instance.natural_key()` and `Model.objects.get_by_natural_key()` method APIs

## Using the Natural Key APIs

The `natural_key()` and `get_by_natural_key()` APIs are symmetric with one another:

    >>> DeviceType.objects.first().natural_key()
    ['MegaCorp', 'Model 9000']

    >>> DeviceType.objects.get_by_natural_key("MegaCorp", "Model 9000")
    <DeviceType: Model 9000>

## Implementing the Natural Key APIs

In many model cases, Nautobot's default implementation of these APIs will suffice. As long as your model has any of the following, a default natural key will be automatically made available:

- One or more `UniqueConstraint` declarations
- Any `unique_together` declaration
- Any field (other than `id`) that is set as `unique=True`.

There are a few special cases that will need special handling as described below.

### Self-Referential Natural Keys

An example of this can be seen with the `Location` model, where a given instance is only uniquely identified by its name in combination with its parent, which is another `Location`. Nautobot's default implementation would fall into an infinite recursion when trying to identify the Location's natural key fields, since they would be identified as `("name", "parent__name", "parent__parent__name", "parent__parent__parent__name", ...)`.

In a case like this, Nautobot is able to support variadic natural keys, where the number and listing of natural keys may vary depending on the data of a given instance. To make this work, you will need to override two APIs related to natural keys on your model (`natural_key_field_lookups` and `natural_key_args_to_kwargs`) as follows:

    class Location(TreeModel):

        class Meta:
            unique_together = [["parent", "name"]]

        @classproperty
        def natural_key_field_lookups(cls):
            """
            Due to the recursive nature of Location's natural key, we need a custom implementation of this property.

            This returns a set of natural key lookups based on the current maximum depth of the Location tree.
            For example if the tree is 2 layers deep, it will return ["name", "parent__name", "parent__parent__name"].

            Without this custom implementation, the generic `natural_key_field_lookups` would recurse infinitely.
            """
            lookups = []
            name = "name"
            for _ in range(cls.objects.max_depth + 1):
                lookups.append(name)
                name = f"parent__{name}"
            return lookups

        @classmethod
        def natural_key_args_to_kwargs(cls, args):
            """Handle the possibility that more recursive "parent" lookups were specified than we initially expected."""
            args = list(args)
            natural_key_field_lookups = list(cls.natural_key_field_lookups)
            while len(args) < len(natural_key_field_lookups):
                args.append(None)
            while len(args) > len(natural_key_field_lookups):
                natural_key_field_lookups.append(f"parent__{natural_key_field_lookups[-1]}")
            return dict(zip(natural_key_field_lookups, args))

### Natural Keys Referencing a Different Self-Referential Model

Similarly, if you have a model whose natural keys include a `ForeignKey` to a model like `Location` with self-referential and variadic natural keys, for the related model to be handled properly, you must always ensure that the related field is the last such field in your model's uniqueness constraint or `natural_key_field_names` declaration.

Instead of this:

    class VLANGroup(BaseModel):
        class Meta:
            unique_together = [["location", "name"]]   # wrong, nested location natural key cannot be variadic

Do this:

    class VLANGroup(BaseModel):
        class Meta:
            unique_together = [["name", "location"]]   # correct, nested location natural key can be variadic

### No Uniqueness Constraints

You really shouldn't implement any models that lack one of the aforementioned uniqueness constraints, but if for some reason you find yourself in this situation, there are two possible approaches you can take:

#### Approximate the Natural Key

Perhaps your model doesn't have any actual database-level uniqueness constraints, but there are one or more fields that practically serve to uniquely identify a model instance. An example case here might be a model that has a `DateTimeField` with `auto_now_add=True` - while it may not be guaranteed unique by the database, in most cases a given timestamp is going to match at most one model instance. In cases like this you can declare `natural_key_field_names` on your model class to explicitly specify the list of "nearly unique" fields that should serve as the natural key for your model:

    class FileProxy(BaseModel):
        name = models.CharField(max_length=255)
        uploaded_at = models.DateTimeField(auto_now_add=True)

        # no UniqueConstraint or unique_together - whoops!

        natural_key_field_names = ["name", "uploaded_at"]

#### Remove the Natural Key

If the model simply lacks any conceivable combination of fields that could uniquely identify a specific model instance, you'll need to explicitly remove the `natural_key` method from your model so that Django doesn't attempt to automatically call it at various points (notably, when running `nautobot-server dumpdata --natural-primary`) and error out. This can be accomplished as follows:

    class MyUnnaturalModel(BaseModel):
        class AttributeRemover:
            def __get__(self, instance, owner):
                raise AttributeError("MyUnnaturalModel doesn't yet have a natural key!")

        natural_key = AttributeRemover()