root@nexthop:~$ cat ./posts/part-4-pushing-config-with-nornir-three-ways-to-make-changes.md

part-4-pushing-config-with-nornir-three-ways-to-make-changes.md

Part 4 - Pushing Config with Nornir: Three Ways to Make Changes

// Three ways to push config with Nornir - from a simple list of commands, to a config file, to a fully dynamic Jinja2 template with per-device variables.

Part 4 - Pushing Config with Nornir: Three Ways to Make Changes
stdout — part-4-pushing-config-with-nornir-three-ways-to-make-changes.md
From Parsed Data to Reports & Backups with Nornir
Learn how to process parsed network data and generate reports and backups using Nornir automation in Python.

Part 3 of the Nornir Series

GitHub - the-next-hop/nornir-network-discovery: A practical Nornir series — from querying your first device to building a network discovery tool. Companion code for The Next Hop.
A practical Nornir series — from querying your first device to building a network discovery tool. Companion code for The Next Hop. - the-next-hop/nornir-network-discovery

GitHub repository used for this series.

In our previous three-part article series on Nornir, we have covered how we can obtain information from our networking devices and how we can structure and manipulate the data that we obtain. In this article, we will cover the basics of how we can send the config to our devices.

I have split the pushing config part into three methods of sending config, starting with a simple list of commands, followed by referencing the configuration from a .txt file, and ending with using the most popular templating language used, Jinja2.

Introduction

As I have mentioned previously in the series already, we look at Nornir (or any Python library in fact) as a modular platform that allows us to use different blocks of code (called functions), depending on the use case. The most common function that we used so far was netmiko_send_command, which is essentially a function used to obtain information from the device (most commonly known in our Network Engineering world as 'show' command).

Function attributes

Now, since we are about to push the config to the devices, we need to use a different function, netmiko_send_config, instead. If we take a look at what arguments are expected in the function (link to nornir_netmiko GitHub HERE if you want to take a deeper look):

 """
    Execute Netmiko send_config_set method (or send_config_from_file)

    Arguments:
        config_commands: Commands to configure on the remote network device.
        config_file: File to read configuration commands from.
        enable: Attempt to enter enable-mode.
        dry_run: Whether to apply changes or not (will raise exception)
        kwargs: Additional arguments to pass to method.

    Returns:
        Result object with the following attributes set:
          * result (``str``): string showing the CLI from the configuration changes.
    """
💡
I strongly recommend going through the code and documentation before you implement anything, not only will it help you to execute the code correctly, but you will also learn a LOT!

As you can see, we have arguments such as config_commands, which is where you list the commands that you want to push. We also have config_file, which is the path to the config file you wish to push. That should get us going!

Pushing config from a list

Picture this: you have been asked to add a new DNS server IP across the fleet. Sounds dreadful if you have to do it manually, but luckily, we live in 2026 (and to be completely honest, Nornir and Netmiko were around for a long time; it's not like they just came out in 2026).

from nornir_netmiko.tasks import netmiko_send_config

def send_commands_from_list(nr):
    
    name_servers = ['8.8.8.8', '8.8.4.4', '1.1.1.1']
    commands = [f"ip name-server {ip}" for ip in name_servers]
    
    try:
        send_commands = nr.run(
            task=netmiko_send_config,
            config_commands=commands
        )

        print_result(send_commands)

    except Exception as e:
        print(f"[{current_time}] Error occurred while attemping to add the name-servers: {e}")

Now, let's review what's different to what we normally do. First and foremost,

name_servers = ['8.8.8.8', '8.8.4.4', '1.1.1.1']

I declare the IPs of the new servers (I have used the most common ones for this use case; normally, it would probably be internal IPs of your DNS within your organisation).

commands = [f"ip name-server {ip}" for ip in name_servers]

Secondly, I create a for loop that will produce one line of config ip name-server x.x.x.x for every DNS server listed in the name_servers list. What the for loop does is, for every single DNS server IP, it produces an individual line of config.

try:
    send_commands = nr.run(
        task=netmiko_send_config,
        config_commands=commands
    )

This is a chunk of code that you should already be familiar with. We are only changing the task from netmiko_send_command to netmiko_send_config, as explained above. As per the documentation seen in Kirk Byers' Git Hub, one of the arguments that we need to pass is config_commands, which in our case, we are pointing to the loop that will produce the ip name-server {{ip}} line, one for each DNS server listed.

And last but not least, we are back to using the print_results function since it will give us a nice printout of what has been applied. Notice how the code is way smaller since I don't have to loop through the AggregatedResults, since we are not obtaining information - we are pushing the config instead.

Result

Let's call the function and see what happens, shall we:

netmiko_send_config*************************************************************
* loki ** changed : True *******************************************************
vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Loki(config)#ip name-server 8.8.8.8
Loki(config)#ip name-server 8.8.4.4
Loki(config)#ip name-server 1.1.1.1
Loki(config)#end
Loki#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* odin ** changed : True *******************************************************
vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Odin(config)#ip name-server 8.8.8.8
Odin(config)#ip name-server 8.8.4.4
Odin(config)#ip name-server 1.1.1.1
Odin(config)#end
Odin#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* thor ** changed : True *******************************************************
vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Thor(config)#ip name-server 8.8.8.8
Thor(config)#ip name-server 8.8.4.4
Thor(config)#ip name-server 1.1.1.1
Thor(config)#end
Thor#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Notice how the netmiko_send_config handles the sequence of configure terminal, followed by end when the desired commands are added.

We have covered what each line of print_result means in our first chapter, but there is one key difference here. The `changed value says True, instead of False. This is due to the fact that the netmiko_send_config function is actually affecting our device by adding config. Now, sadly, this will always say True, even if you send the same config twice (meaning it already exists). This is due to the fact that Netmiko is not idempotent. It will always assume that the config needs to be added, and as long as you are using the send_config function, it will always say True.

What is idempotency? Idempotency means - running the same operation multiple times where it produces the exact same result every single time.

Now, tools like Ansible or even NAPALM, can handle this for us - but not Netmiko.

Pushing config from a file

You can pretty much achieve the exact same outcome with a text file. You don't have to declare the configuration in the Python script; you can just list the commands in a text file. As long as the config is not unique per device, this will work just fine.

def send_config_from_file(nr):
    
    try:
        send_config = nr.run(
            task=netmiko_send_config,
            config_file=f"{templates_dir}/ntp.txt"
        )

        print_result(send_config)

    except Exception as e:
        print(f"[{current_time}] Error occurred while attemping to add the config from file: {e}")

In this example, I am pointing the config_file to the templates directory that I have created in Chapter 1, followed by the ntp.txt, which is where the config is stored.

ntp server 216.239.35.0
ntp server 216.239.35.4
ntp server 162.159.200.1

As you have probably noticed, everything else is the same!

Results

vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Loki(config)#ntp server 216.239.35.0
Loki(config)#ntp server 216.239.35.4
Loki(config)#ntp server 162.159.200.1
Loki(config)#end
Loki#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* odin ** changed : True *******************************************************
vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Odin(config)#ntp server 216.239.35.0
Odin(config)#ntp server 216.239.35.4
Odin(config)#ntp server 162.159.200.1
Odin(config)#end
Odin#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* thor ** changed : True *******************************************************
vvvv netmiko_send_config ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Thor(config)#ntp server 216.239.35.0
Thor(config)#ntp server 216.239.35.4
Thor(config)#ntp server 162.159.200.1
Thor(config)#end
Thor#
^^^^ END netmiko_send_config ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Jinja2 introduction

As mentioned at the beginning of the article, Jinja2 is an extremely powerful templating engine which allows us to use special placeholders in the template, allowing us to write code similar to Python syntax, which allows us to produce a configuration unique to each device. It is crucial to be familiar and comfortable with Jinja 2 since it's widely used in the industry, across different platforms within the Network Automation world.

I am not going to explain Jinja2 in greater detail in this article; I will cover the basics and display how to implement this into our use case.

What do we need?

First, we need to declare values that will be targeted by Jinja2, in other words, the unique values that we need to be generated per device; they have to be stored somewhere.

Let's assume that we need to add SNMP details across our fleet, and as you are aware, these could consist (at the very minimum) of site location and community string (and many more; this is just for the sake of this article).

Since Jinja2 heavily relies on structured data, in this case YAML, we will store our variables in hosts.yaml since it's nice and easy.

hosts.yaml

---
odin:
  hostname: 192.168.1.21
  groups:
    - cisco_ios
  data:
    snmp:
      - location: "Made up street 10, New York City"
        community: "communi1tyStr1nG" 

thor:
  hostname: 192.168.1.22
  groups:
    - cisco_ios
  data:
    snmp:
      - location: "Made up street 20, Los Angeles"
        community: "communi1tyStr1nG" 
      
loki:
  hostname: 192.168.1.23
  groups:
    - cisco_ios 
  data:
    snmp:
      - location: "Made up street 30, San Francisco"
        community: "communi1tyStr1nG" 

Within our original host file, I have added a new key called data, which has a nested key-value pair of snmp, that contains a list of the relevant information, unique to the device. Anything that we specify here on top of the device details, can be called however you wish, although it is a best practice to keep the naming convention as relevant as possible for ease of understanding.

snmp.j2

Now let's create a Jinja2 file (notice the .j2 extension, which is what Jinja2 is using). This is what we will use to dynamically generate the config for each host. I will store this within our templates directory.

{% for snmp in host.data.snmp %}
    snmp-server location {{ snmp.location }}
    snmp-server community {{ snmp.community }}
{% endfor %}

This should start making more sense now. The beauty of Jinja2 templating is that we can add as many devices as we want in the host.yaml, and as long as the key-value structure is retained, Jinja2 will generate a config for each device. As mentioned in the introduction, the structure of the code is very similar to Python.

{% for snmp in host.data.snmp %} - This is no different to the loop written in a Pythonic way. What we are saying is, loop through everything that's nested within the host > data > snmp. , which is how we structured it in the host.yaml. For each SNMP entry in the SNMP list, we are calling it snmp while we're looping.

Within the loop, we generate the code with the values specific to each host, dynamically.

snmp-server location {{ snmp.location }}
snmp-server community {{ snmp.community }}

Task function

Now that we have the prerequisites out of the way, we have to make them aware of each other. Here is the example of the function that we will use:

from nornir_jinja2.plugins.tasks import template_file

def send_config_from_template(task):
    try:
        rendered_config = task.run(
            task=template_file,
            template="snmp.j2",
            path=templates_dir,
            **task.host
        )
        
        task.run(
            task=netmiko_send_config,
            config_commands=rendered_config.result.splitlines()
        )
        
    except Exception as e:
        print(f"[{current_time}] Error occurred while attemping to add the config from template: {e}")

First and foremost, since we are using a specific function for Jinja2 templating, we need to import the from nornir_jinja2.plugins.tasks import template_file

Notice how we are no longer using the nr.run, but we have now converted ot task.run. This is because we are no longer executing one 'job' per sequence; we now have to do two things within a single call: first, render the template and then send the rendered configuration to the device.

I haven't introduced you to the Task just yet, because I wanted to keep things simple. Think of it that way, a task is capable of a workflow on a per-device basis, whereas the nr.run() just executes across all devices (hence why we have two of task.run in our function above).

Connecting the dots

Within the renderd_config variable, we have a task of task=template_file. This is telling Nornir that we will be relying on Jinja2 for this task, meaning we need to render the config based on the template.

template="snmp.j2",

This is telling Nornir the name of our template file.

path=templates_dir,

This part points Nornir to the correct directory where the template is stored

**task.host

Additional data to pass to the template. We are pointing Jinja2 to where the variables are stored.

More on the above can be found in the Nornir documentation HERE.

Results

send_config_from_template*******************************************************
* loki ** changed : True *******************************************************
vvvv send_config_from_template ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- template_file ** changed : False ------------------------------------------ INFO

    snmp-server location Made up street 30, San Francisco
    snmp-server community communi1tyStr1nG

---- netmiko_send_config ** changed : True ------------------------------------- INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Loki(config)#
Loki(config)#    snmp-server location Made up street 30, San Francisco
Loki(config)#    snmp-server community communi1tyStr1nG
Loki(config)#end
Loki#
^^^^ END send_config_from_template ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* odin ** changed : True *******************************************************
vvvv send_config_from_template ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- template_file ** changed : False ------------------------------------------ INFO

    snmp-server location Made up street 10, New York City
    snmp-server community communi1tyStr1nG

---- netmiko_send_config ** changed : True ------------------------------------- INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Odin(config)#
Odin(config)#    snmp-server location Made up street 10, New York City
Odin(config)#    snmp-server community communi1tyStr1nG
Odin(config)#end
Odin#
^^^^ END send_config_from_template ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* thor ** changed : True *******************************************************
vvvv send_config_from_template ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- template_file ** changed : False ------------------------------------------ INFO

    snmp-server location Made up street 20, Los Angeles
    snmp-server community communi1tyStr1nG

---- netmiko_send_config ** changed : True ------------------------------------- INFO
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
Thor(config)#
Thor(config)#    snmp-server location Made up street 20, Los Angeles
Thor(config)#    snmp-server community communi1tyStr1nG
Thor(config)#end
Thor#
^^^^ END send_config_from_template ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

As you can see, the printed output follows the sequence of the tasks. First, on a per-host basis, it generates the config based on the template, followed by injecting it with the other task of netmiko_send_config.

Now, because the task consists of a sequence, I am wrapping the print_result outside of the function

render_config = nr.run(task=send_config_from_template)
print_result(render_config)

The render_config is a variable that runs nr.run, with a task which essentially is the function name that we created above.

Nornir series

Across four parts, we have gone from querying a single device with a handful of lines of Python, to building a tool that runs a sequence of commands, parses structured data, exports reports, backs up configs and pushes changes - all in parallel, all from a single script.

If you have followed along from Part 1, you now have a foundation that you can genuinely build on. Add more functions, extend the inventory, point it at the real network and see the true value in the Network Automation.

This series covers the absolute basics of the Network Automation world, and naturally, this is just the tip of the iceberg. Sure, there are other tools that are beneficial for what you need, whether that is Nornir with NAPALM instead of Netmiko, or Ansible that solely relies on pre-defined libraries and predominantly YAML (no Python, unless you absolutely want). You can decide which tool suits your needs best.

In the upcoming weeks, I will create an introduction to Ansible, Network Infrastructure as Code, and I am hoping to write articles about the day-to-day challenges as a Network Engineer.

The full code for this series is available on GitHub, linked at the top of each article.

reactions.sh
# Did this help you?
// loading reactions…

root@nexthop:~$ cd ~/ // back to index