Device API-First: Automating with RESTCONF/NETCONF
Model the network, not the CLI — practical patterns you can ship tomorrow.
Device API-First: Automating with RESTCONF/NETCONF
Model the network, not the CLI — practical patterns you can ship tomorrow.
Photo by Igor Omilaev on Unsplash
If you’ve ever wrote a brittle “send these five commands” script and held your breath while pasting into 40 switches, this article is your soft landing. Instead of screen-scraping CLIs, we’ll treat devices as APIs and push changes in a way that’s structured, testable, and reversible. Two workhorses make this possible:
- NETCONF: an RPC protocol over SSH that manipulates YANG-modeled configuration and state (XML payloads, transactional features like candidate, validate, commit confirmed).
- RESTCONF: a RESTful interface that exposes the same YANG data via HTTP verbs (GET/POST/PUT/PATCH/DELETE) with JSON or XML bodies.
We’ll keep the tone conversational and the examples real: copy-pasteable curl, small Python programs (requests and ncclient), and deployment patterns that scale from a home lab to a multi-vendor fabric. By the end, you’ll be able to:
- Read and write device configuration by model (IETF or OpenConfig), not by CLI.
- Use idempotent operations with dry-run style diffs/validations.
- Safely commit, rollback, and validate your intent — without praying to the copy-paste gods.
Device API-First, in one sentence
Model first, transport second: decide what data model (YANG) you want to manipulate (e.g., ietf-interfaces, openconfig-bgp), then choose how to transport it (NETCONF for RPC/transactions, RESTCONF for HTTP simplicity). When you anchor on the model, your code reads like “set interface X to IP Y” rather than “send these 7 CLI lines.”
The mental model: YANG → (NETCONF | RESTCONF)
- YANG is the schema language describing your device’s data: containers, lists, leafs, types, constraints. Think “tables and columns,” but for network state and config.
- NETCONF wraps YANG in an RPC protocol over SSH (port 830 on most vendors). You send XML; you get XML; you can
lock,validate,edit-config,commit, androllback. - RESTCONF maps YANG to HTTP paths and methods (usually
/restconf/data/...). You send JSON or XML with standard verbs. It’s friendlier to web tooling and gateways.
Same data, different delivery drivers.
What we’ll use (tooling)
- Python 3.10+
requestsfor RESTCONFncclientfor NETCONFrichfor nice prints (optional)
requirements.txt:
requests
ncclient
rich
Create a venv and install:
python -m venv env
source env/bin/activate # Windows: env\Scripts\activate
pip install -r requirements.txt
⚠️ Production note: pin versions and use a proper secrets manager. Lab creds in env vars are fine for learning.
Enabling APIs on devices (quick notes)
Vendors differ, but the idea is consistent:
- NETCONF: enable the NETCONF server over SSH (often
netconf sshor equivalent). Port 830 by default. - RESTCONF: enable the RESTCONF service (HTTPS). Many platforms serve it under
/restconf. - Auth: use accounts with least privilege; prefer SSH keys for NETCONF and certificates for RESTCONF where possible.
If you’re using IOS-XE, Arista EOS, or Junos, these are typically one-liners in global config or system configuration. Check your platform guide for exact commands and ACL/TLS details.
How RESTCONF paths work (no mystery, just mapping)
Map YANG to paths like this:
/restconf/data/<module>:<container>/<list>=<key>/<leaf>
For example, IETF interfaces:
/restconf/data/ietf-interfaces:interfaces/interface=Loopback123
JSON body uses the fully qualified top-level container name:
{
"ietf-interfaces:interface": {
"name": "Loopback123",
"type": "iana-if-type:softwareLoopback",
"enabled": true,
"ietf-ip:ipv4": {
"address": [
{ "ip": "192.0.2.10", "netmask": "255.255.255.255" }
]
}
}
}
HTTP rules of thumb:
- GET: read data (
Accept: application/yang-data+json) - PUT: create/replace a specific resource (idempotent)
- PATCH: partial update (merge semantics)
- POST: create under a collection (server chooses key)
- DELETE: remove
Quick RESTCONF practice: create a loopback (cURL + Python)
cURL
DEVICE=10.0.0.11
USER=netops
PASS='labpass'
BASE="https://${DEVICE}/restconf/data"
HDR_ACCEPT="Accept: application/yang-data+json"
HDR_CT="Content-Type: application/yang-data+json"
# Create/replace Loopback123
curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
-X PUT \
"$BASE/ietf-interfaces:interfaces/interface=Loopback123" \
-d '{
"ietf-interfaces:interface": {
"name": "Loopback123",
"type": "iana-if-type:softwareLoopback",
"enabled": true,
"ietf-ip:ipv4": {
"address": [
{"ip":"192.0.2.10", "netmask":"255.255.255.255"}
]
}
}
}'
Verify:
curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" \
"$BASE/ietf-interfaces:interfaces/interface=Loopback123"
Delete:
curl -sk -u "$USER:$PASS" -X DELETE \
"$BASE/ietf-interfaces:interfaces/interface=Loopback123"
Python (requests)
# restconf_loopback.py
import os, requests, json
from rich import print
requests.packages.urllib3.disable_warnings()
DEVICE = os.getenv("DEVICE", "10.0.0.11")
USER = os.getenv("USER", "netops")
PASS = os.getenv("PASS", "labpass")
BASE = f"https://{DEVICE}/restconf/data"
HDRS = {
"Accept": "application/yang-data+json",
"Content-Type": "application/yang-data+json"
}
payload = {
"ietf-interfaces:interface": {
"name": "Loopback123",
"type": "iana-if-type:softwareLoopback",
"enabled": True,
"ietf-ip:ipv4": {
"address": [{"ip": "192.0.2.10", "netmask": "255.255.255.255"}]
}
}
}
with requests.Session() as s:
s.auth = (USER, PASS)
s.verify = False # lab only; use proper TLS in prod
# PUT is idempotent here
r = s.put(f"{BASE}/ietf-interfaces:interfaces/interface=Loopback123",
headers=HDRS, data=json.dumps(payload))
print("[bold]PUT status[/bold]:", r.status_code, r.text)
r = s.get(f"{BASE}/ietf-interfaces:interfaces/interface=Loopback123",
headers={"Accept": "application/yang-data+json"})
print("[bold]GET[/bold]:", r.status_code, r.json())
Idempotency check: run it twice — the second PUT should return success without changing anything (201/204 depending on the platform).
Quick NETCONF practice: create the same loopback (ncclient)
# netconf_loopback.py
from ncclient import manager
from rich import print
DEVICE = "10.0.0.11"
USER = "netops"
PASS = "labpass"
config = """
<config>
<interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
<interface>
<name>Loopback123</name>
<type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">
ianaift:softwareLoopback
</type>
<enabled>true</enabled>
<ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
<address>
<ip>192.0.2.10</ip>
<netmask>255.255.255.255</netmask>
</address>
</ipv4>
</interface>
</interfaces>
</config>
"""
with manager.connect(host=DEVICE, port=830, username=USER, password=PASS,
hostkey_verify=False, allow_agent=False,
look_for_keys=False, timeout=30) as m:
# Use candidate if supported; else target='running'
try:
print("[bold cyan]Lock candidate[/bold cyan]")
m.lock('candidate')
print("[bold cyan]Edit config[/bold cyan]")
m.edit_config(target='candidate', config=config)
print("[bold cyan]Validate[/bold cyan]")
m.validate()
print("[bold cyan]Commit[/bold cyan]")
m.commit()
except Exception as e:
print("[red]Error, attempting discard[/red]", e)
try:
m.discard_changes()
except Exception:
pass
finally:
try:
m.unlock('candidate')
except Exception:
pass
# Read back with subtree filter
flt = """
<filter>
<interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
<interface>
<name>Loopback123</name>
</interface>
</interfaces>
</filter>
"""
reply = m.get_config(source='running', filter=flt)
print("[bold]GET-CONFIG reply[/bold]:")
print(reply.xml)
Why NETCONF here? You get transactions: lock → edit → validate → commit. If validation fails, no partial config leaks into running.
Commit confirmed (safer yet):
# Ask device to auto-rollback unless we confirm within N minutes
m.commit(confirmed=True, confirm_timeout=3) # minutes
# ... run post-checks ...
m.commit() # confirm the change
Reading state, not just config (both protocols)
- RESTCONF:
/restconf/data/ietf-interfaces:interfaces-state(or/operationalon some platforms). - NETCONF:
get(operational) vsget-config(config). Use subtree or XPath filters to keep responses small.
Example NETCONF operational read:
flt = """
<filter>
<interfaces-state xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
</filter>
"""
reply = m.get(filter=flt)
OpenConfig vs. IETF vs. vendor models
- IETF modules (
ietf-interfaces,ietf-ip,ietf-routing) are widely supported and great for generic tasks. - OpenConfig is community-driven, with rich models (e.g.,
openconfig-bgp). Great for multi-vendor strategies. - Vendor namespaces cover platform-specific features not standardized yet.
Pick one policy: prefer generic (IETF/OpenConfig) unless you truly need vendor knobs. You can always mix: model-agnostic for 90%, vendor models for the last mile.
Practical pattern #1 — Idempotent RESTCONF Interface Template
We’ll capture intent in JSON (host-agnostic), render keys, and issue a PUT so you can run it repeatedly without drift.
# restconf_iface_template.py
import os, json, requests
from jinja2 import Template
requests.packages.urllib3.disable_warnings()
DEVICE = os.getenv("DEVICE", "10.0.0.11")
USER = os.getenv("USER", "netops")
PASS = os.getenv("PASS", "labpass")
T = Template("""
{
"ietf-interfaces:interface": {
"name": "{{ name }}",
"type": "iana-if-type:softwareLoopback",
"enabled": true,
"ietf-ip:ipv4": {
"address": [{ "ip": "{{ ip }}", "netmask": "{{ netmask }}" }]
}
}
}
""")
def upsert_loopback(name, ip, netmask):
base = f"https://{DEVICE}/restconf/data"
hdrs = {"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
body = T.render(name=name, ip=ip, netmask=netmask)
with requests.Session() as s:
s.auth = (USER, PASS); s.verify = False
r = s.put(f"{base}/ietf-interfaces:interfaces/interface={name}",
headers=hdrs, data=body)
return r.status_code, r.text
print(upsert_loopback("Loopback200", "198.51.100.10", "255.255.255.255"))
Run it twice; the second time is a no-op because PUT replaces the resource with the same content.
Practical pattern #2 — Transactional NETCONF with pre/post checks
We’ll guard changes with validations and a rollback path.
# netconf_guarded_change.py
from ncclient import manager
from rich import print
DEVICE="10.0.0.11"; USER="netops"; PASS="labpass"
def bgp_neighbor_up(m, nbr):
# Replace with OpenConfig or IETF routing model checks as supported
try:
flt = """
<filter xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
<bgp-state xmlns="http://openconfig.net/yang/bgp"/>
</filter>
"""
# Some vendors don't implement this; handle gracefully
r = m.get(filter=flt)
return nbr in r.xml and "<state><session-state>ESTABLISHED</session-state>" in r.xml
except Exception:
return True # Don't block if model unsupported in lab
config = """... an XML <config> block to add a BGP neighbor ..."""
with manager.connect(host=DEVICE, port=830, username=USER, password=PASS,
hostkey_verify=False, allow_agent=False, look_for_keys=False) as m:
m.lock('candidate')
try:
if not bgp_neighbor_up(m, "203.0.113.1"):
print("[yellow]Precheck: neighbor not up (expected). Proceeding.[/yellow]")
m.edit_config(target='candidate', config=config)
m.validate()
# Safer: commit confirmed, then confirm after post-check
m.commit(confirmed=True, confirm_timeout=5)
ok = bgp_neighbor_up(m, "203.0.113.1")
if not ok:
print("[red]Post-check failed-auto rollback will occur[/red]")
else:
m.commit() # confirm
print("[green]Committed[/green]")
finally:
try: m.unlock('candidate')
except Exception: pass
This pattern (precheck → edit → validate → commit-confirmed → postcheck → confirm) is your “transaction with safety net.”
Practical pattern #3 — Multi-device concurrency (RESTCONF)
HTTP is I/O-bound; threads work well. Throttle to avoid hammering AAA and CPUs.
# restconf_fleet.py
import os, json, requests
from concurrent.futures import ThreadPoolExecutor, as_completed
requests.packages.urllib3.disable_warnings()
HDRS = {"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
INV = [
{"host":"10.0.0.11","user":"netops","pass":"labpass"},
{"host":"10.0.0.12","user":"netops","pass":"labpass"},
# add more...
]
def put_loopback(dev, name, ip):
base = f"https://{dev['host']}/restconf/data"
payload = {
"ietf-interfaces:interface": {
"name": name, "type":"iana-if-type:softwareLoopback", "enabled": True,
"ietf-ip:ipv4":{"address":[{"ip": ip, "netmask":"255.255.255.255"}]}
}
}
with requests.Session() as s:
s.auth = (dev["user"], dev["pass"]); s.verify = False
r = s.put(f"{base}/ietf-interfaces:interfaces/interface={name}",
headers=HDRS, data=json.dumps(payload), timeout=20)
return dev["host"], r.status_code
def main():
futures=[]
with ThreadPoolExecutor(max_workers=10) as pool:
for d in INV:
futures.append(pool.submit(put_loopback, d, "Loopback99", "203.0.113.99"))
for f in as_completed(futures):
host, status = f.result()
print(host, status)
if __name__ == "__main__":
main()
Pro tips:
- Add small random jitter to starts.
- Centralize retries with backoff (HTTP 5xx, 429).
- Consider ETag/
If-Matchfor optimistic concurrency if your platform supports it.
Practical pattern #4 — OpenConfig BGP via RESTCONF (JSON)
This shows the flavor; adapt paths to your platform’s OpenConfig support.
Path:
/restconf/data/openconfig-bgp:bgp/neighbors/neighbor=203.0.113.1/config
Payload:
{
"openconfig-bgp:config": {
"neighbor-address": "203.0.113.1",
"peer-as": 65002,
"description": "Edge peer"
}
}
Python:
# restconf_bgp_neighbor.py
import json, requests
requests.packages.urllib3.disable_warnings()
BASE="https://10.0.0.11/restconf/data"
HDRS={"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
with requests.Session() as s:
s.auth=("netops","labpass"); s.verify=False
r = s.put(f"{BASE}/openconfig-bgp:bgp/neighbors/neighbor=203.0.113.1/config",
headers=HDRS,
data=json.dumps({"openconfig-bgp:config":{
"neighbor-address":"203.0.113.1",
"peer-as":65002,
"description":"Edge peer"}}))
print(r.status_code, r.text)
If your device wants you to create the parent collection first, POST the neighbor object under /neighbors instead of PUTting the specific neighbor path—behavior varies.
Practical pattern #5 — NETCONF filters that scale
Avoid pulling the entire config. Use subtree filters so responses are small and fast.
flt = """
<filter>
<routing xmlns="urn:ietf:params:xml:ns:yang:ietf-routing">
<routing-instance>
<name>default</name>
<routing-protocols/>
</routing-instance>
</routing>
</filter>
"""
reply = m.get_config(source='running', filter=flt)
XPath filters are also supported on many platforms:
reply = m.get(xpath="/interfaces/interface[name='Loopback123']")
Error handling & observability
RESTCONF:
- HTTP 200/201/204 → OK,
- 400/409/412/415 → model or method issues (bad schema, conflict, precondition failed, unsupported media type),
- 401/403 → auth/authorization,
- 404 → path not found (model not present or wrong namespace),
- 5xx → device unhappy.
Parse JSON error bodies if provided; vendors often include helpful error-tag/error-info tied to the underlying YANG validation.
NETCONF:
- Exceptions expose
<rpc-error>with fields:error-type,error-tag,error-severity,error-info. - Wrap steps and always be able to
discard-changesor rely oncommit confirmedto auto-rollback.
Logging:
- Log path, payload, response code/body. Redact secrets.
- Keep per-device request IDs (correlate across retries).
- Archive pre/post snapshots for CAB/troubleshooting.
Security basics you’ll thank yourself for
- SSH keys for NETCONF; TLS with real certs for RESTCONF.
- NACM / RBAC: don’t give the automation account global write; scope it to the models/paths it needs.
- Secrets: env vars for labs; Vault/Secrets Manager in prod. Rotate!
- Network ACLs: restrict API ports (830 for NETCONF, 443 for RESTCONF) to your automation workers.
Performance tips
- Use HTTP keep-alive via
requests.Session. - Prefer PUT idempotent upserts over POST when the key is known.
- Batch work with a ThreadPoolExecutor; tune concurrency to your AAA/backend limits.
- For info-heavy tasks, prefer NETCONF filters or RESTCONF selective paths rather than reading everything.
Designing your “API-first” codebase (small but sane)
A minimal shape:
api-first/
├─ inventory/devices.yaml
├─ lib/
│ ├─ restconf.py # thin wrapper (session, headers, get/put/patch)
│ ├─ netconf.py # thin wrapper (connect, edit, commit, confirmed)
│ ├─ models/ # paths & payload builders per YANG model
│ └─ checks.py # pre/post validators
├─ tasks/
│ ├─ upsert_loopback.py
│ ├─ add_bgp_neighbor.py
│ └─ verify_ntp.py
└─ main.py # argparse CLI
Keep models and payload builders separate from transports so you can flip between RESTCONF and NETCONF without rewriting your logic.
Choosing RESTCONF vs. NETCONF (cheat-sheet)
Use case Pick Why Quick reads/writes, easy tooling, JSON preference RESTCONF HTTP, requests, curl—great ergonomics Transactions with lock/validate/rollback NETCONF candidate, validate, commit confirmed Bulk concurrent reads from many devices RESTCONF Lightweight, easy to fan out Precise, schema-validated edits with guaranteed semantics NETCONF RPCs map cleanly to config transactions Streaming telemetry / subscriptions (model-driven) NETCONF (YANG-Push) Standardized subscriptions/notifications Gateways, proxies, auth delegation RESTCONF Plays well with API gateways & OAuth fronts
In practice you’ll mix them: RESTCONF for fleet reads and simple idempotent writes, NETCONF for the scary changes that demand transactions.
Common “gotchas” and how to dodge them
- Wrong namespace in RESTCONF → 404s. Use fully qualified module names (
ietf-interfaces:interfaces). - JSON vs XML mismatch → 415 Unsupported Media Type. Check
Content-TypeandAccept. - Device lacks model → 404 or RPC error. Fall back to vendor model or adjust feature scope.
- Partial config drift → favor PUT (replace) over PATCH (merge) when possible; or combine PATCH with post-read validation to ensure your intent landed.
- Over-concurrency → AAA lockouts. Start with 5–10 threads; monitor and tune.
- Skipping validation → run something after every change (even a
GETto confirm the leaf you set).
A complete mini-workflow (copy this for your next CAB)
1. Inventory: load devices and their capabilities (RESTCONF/NETCONF).
2. Precheck: read current state; ensure you’re not about to break the world (neighbors up, CPU sane, etc.).
3. Change:
- If reversible/critical → NETCONF
candidate+validate+commit confirmed - If simple/idempotent → RESTCONF
PUTwith a full resource body.
4. Postcheck: re-read the relevant YANG paths; compare to intent.
5. Confirm or rollback: confirm the commit or let it auto-rollback; for RESTCONF, perform a compensating PUT/DELETE if postcheck fails.
6. Archive: store requests, responses, and a compliance digest.
This is the “render → dry-run (validate) → commit → verify → archive” loop — API-first style.
Appendix: a few more handy snippets
RESTCONF list creation with POST (server picks the key)
Some collections use POST to create a new child if the key is generated on the device.
curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
-X POST \
"$BASE/example-mod:things" \
-d '{ "example-mod:thing": { "config": { "description": "auto-created" } } }'
RESTCONF partial update with PATCH (merge)
curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
-X PATCH \
"$BASE/ietf-interfaces:interfaces/interface=Loopback123" \
-d '{ "ietf-interfaces:interface": { "enabled": false } }'
NETCONF “replace” edit
<config>
<interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces" nc:operation="replace"
xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
<interface>
<name>Loopback123</name>
<enabled>false</enabled>
</interface>
</interfaces>
</config>
NETCONF notifications / YANG-Push (sketch)
# Many vendors require a specific namespace & RPC; adjust for your platform
sub = """
<establish-subscription xmlns="urn:ietf:params:xml:ns:yang:ietf-subscribed-notifications">
<stream>yang-push</stream>
<filter type="subtree">
<interfaces-state xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
</filter>
<period>5000</period> <!-- 5s -->
</establish-subscription>
"""
m.dispatch(to_ele(sub))
for n in m.take_notifications(timeout=60):
print(n.notification_xml)
Wrapping it up
“Device API-first” isn’t hype; it’s a calmer way to operate networks. When you express intent with YANG models and use protocols built for configuration, the blast radius shrinks and your work becomes predictable:
- RESTCONF gives you ergonomic, idempotent HTTP calls — perfect for fast reads and simple writes at scale.
- NETCONF gives you transactions — lock, validate, commit confirmed — which make scary changes boring.
Most teams end up mixing both: RESTCONF for fleet-wide inventory and small upserts, NETCONF for anything that deserves a dry-run and a safety net. Along the way, lean on IETF and OpenConfig models to stay vendor-neutral as much as possible, and dip into vendor namespaces only when you must.
If you carry one habit into your next maintenance window, make it this loop: render intent → validate/dry-run → commit (confirmed) → post-check → archive (and auto-rollback if needed). That’s API-first — and it scales with your ambition.
TL;DR index of examples
restconf_loopback.py: idempotent interface upsert over RESTCONFnetconf_loopback.py: transactional interface creation withcandidate/commitrestconf_fleet.py: concurrent RESTCONF across devicesrestconf_bgp_neighbor.py: OpenConfig BGP neighbor via JSONnetconf_guarded_change.py: commit confirmed with pre/post checks
메타데이터
- post_id
- efcd4e5dfb84
- slug
- device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
- url
- https://medium.com/@hmbali96/device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
- canonical_url
- https://medium.com/@hmbali96/device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
- author_url
- https://medium.com/@hmbali96
- status
- ok
- fetched_at
- 2026-07-26 17:34:23