The Oxidized post gave me a Git history of every router and switch config. That tells you what changed after it happened. Batfish works the other way round: you give it the configs you're about to push, and it builds a model of the network offline and answers questions about it. Will this ACL do what I think? Does this new route break reachability? You find out before anything touches a device.
I tested it against the configs Oxidized pulled from my home lab: two Cisco IOS XE 17.18 WAN routers running OSPF and eBGP between them, and an IOS XE access switch behind wan-rtr1. Everything here was built in my home lab.

Versions and licence
- Batfish, container image
batfish/allinone:latest, built 2026-08-27 (about 2.2 GB). Apache-2.0. - pybatfish 2026.08.27.3685 (bundled in the allinone image; 2026.9.17 is on PyPI). Apache-2.0.
The allinone image bundles the Batfish service, pybatfish and Jupyter. I didn't use Jupyter; I ran a plain Python script inside the container.
Setup
docker run -d --name pl-batfish batfish/allinone:latestBatfish works on snapshots. A snapshot is just a directory with a configs/ folder holding one file per device. I exported the latest version of each device from the Oxidized Git repo:
for n in wan-rtr1 wan-rtr2 acc-sw1; do
git --git-dir=configs.git show HEAD:$n > base/configs/$n.cfg
doneThat's the base snapshot, the network as it is now. Then I copied it to a candidate snapshot and made the change I wanted to test. Snapshots live in Git and Batfish never logs into anything, so this whole workflow can run in a CI job.
First, be honest about parsing
Before trusting any answer, check how much of the configs Batfish understood. It's the step people skip:
from pybatfish.client.session import Session
bf = Session(host="localhost")
bf.set_network("pinglabz")
bf.init_snapshot("/snap/base", name="base", overwrite=True)
bf.q.fileParseStatus().answer().frame()
bf.q.initIssues().answer().frame()
bf.q.parseWarning().answer().frame()
None of the three files parsed cleanly. Every one came back PARTIALLY_UNRECOGNIZED, with 23 parse warnings in total. Here's what Batfish couldn't read:
option interface-tableandoption sampler-tableunder the Flexible NetFlow exporter. Batfish doesn't model NetFlow at all.snmp-server host ... vrf ... version 2c. It skipped the SNMP trap host with a VRF.logging trap syslog-format rfc5424,no logging btrace,ip ssh bulk-mode,hash sha512. These are newer IOS XE knobs that the grammar hasn't caught up with.ip default-gatewayon the switch, which it flags as "not currently supported".
None of that touches forwarding, so I was fine. The rule I'd use: read every warning, and if one lands on routing, an ACL, NAT or an interface, don't trust the answers until you've dealt with it. On older or more exotic platforms the list gets longer.
The part that matters did parse. Batfish worked out the eBGP session (AS 65001 to 65002) and the OSPF adjacency on its own, and both show ESTABLISHED, matching the real routers:

The change: CHG-1043
Here's the candidate change. It contains two mistakes I've seen in real change windows.
On wan-rtr1, apply EDGE-IN inbound on the LAN-facing interface, with the lines in the wrong order. The intent is to block telnet, but the catch-all permit sits above the deny:
ip access-list extended EDGE-IN
10 permit tcp 10.0.0.0 0.255.255.255 any eq 22
20 permit icmp any any echo-reply
30 permit ip any any
40 deny tcp any any eq telnet log
!
interface Ethernet0/2
ip access-group EDGE-IN inOn wan-rtr2, someone adds a "summary" discard route for the LAN, thinking it only matters when the more specific route is gone:
ip route 10.0.10.0 255.255.255.0 Null0 name CHG-1043-summaryThat route is the same /24 that wan-rtr2 already learns over eBGP, not a shorter summary. A static has admin distance 1 and eBGP has 20, so the static wins and the traffic gets dropped.
The questions
bf.init_snapshot("/snap/cand", name="cand", overwrite=True)
# ACL lines that can never match
bf.q.filterLineReachability(nodes="wan-rtr1").answer(snapshot="cand")
# would telnet from the LAN be blocked?
bf.q.testFilters(nodes="wan-rtr1", filters="EDGE-IN",
headers=HeaderConstraints(srcIps="10.0.10.2", dstIps="10.255.0.1",
ipProtocols=["tcp"], dstPorts="23")).answer(snapshot="cand")
# what does wan-rtr2 use for the LAN, before and after?
bf.q.routes(nodes="wan-rtr2", network="10.0.10.0/24").answer(snapshot="base")
bf.q.routes(nodes="wan-rtr2", network="10.0.10.0/24").answer(snapshot="cand")
# trace SSH from wan-rtr2's loopback to the switch, in both snapshots
bf.q.traceroute(startLocation="wan-rtr2",
headers=HeaderConstraints(srcIps="10.255.0.2", dstIps="10.0.10.2",
applications=["ssh"])).answer(snapshot="cand")
# every flow to the LAN that behaves differently after the change
bf.q.differentialReachability(headers=HeaderConstraints(dstIps="10.0.10.0/24")
).answer(snapshot="cand", reference_snapshot="base")What Batfish found

### filterLineReachability cand
Unreachable_Line Blocking_Lines Reason
40 deny tcp any any eq telnet log ['30 permit ip any any'] BLOCKING_LINES
### testFilters telnet cand
Flow Action Line_Content
start=wan-rtr1 [10.0.10.2:49152->10.255.0.1:23 TCP (SYN)] PERMIT 30 permit ip any any
### routes wan-rtr2 10.0.10.0/24
base: 10.0.10.0/24 ip 10.0.12.1 bgp AD 20
cand: 10.0.10.0/24 discard static AD 1
### traceroute wan-rtr2 -> acc-sw1
base: ACCEPTED wan-rtr2 -> wan-rtr1 -> acc-sw1
cand: NULL_ROUTED wan-rtr2:ORIGINATED,NULL_ROUTEDBoth mistakes were caught:
- The ACL. Line 40 can never match because line 30 already permits everything, and
testFiltersconfirms that telnet from the LAN is permitted by line 30. On a real router, IOS XE would accept that ACL without a word of complaint. - The route. wan-rtr2 swaps a BGP route for a discard route, and the traceroute that worked in the base snapshot is now null-routed.
differentialReachabilityfinds the broken flow without me having to guess which source and destination to test.
Fixing both is a one-line change each: move the deny above the permit (or renumber it to 25), and either drop the static route or give it a higher admin distance (ip route 10.0.10.0 255.255.255.0 Null0 250) so it's only used when BGP loses the route. Then re-run the script against the fixed candidate. That's the loop: edit the candidate, re-run, push only when it's clean.
What Batfish can't tell you
- It only knows what's in the configs. It can't see link state, the hardware, or the routes your neighbours advertise unless you model them, and it doesn't know about traffic volumes.
- Anything it doesn't parse is ignored silently in the answers. That's why the parse check comes first. In my configs, NetFlow, some SNMP and logging lines, and
ip default-gatewaywere skipped. - It doesn't model NetFlow, SNMP, AAA or the management plane, so it can't tell you whether your monitoring will still work after the change.
- The image is about 2.2 GB, and it wants a few GB of RAM for a small network. That's nothing on a server, but you'll notice it on a laptop.
What's next
Oxidized gives you the history and Batfish checks the candidate. Next, the configs need a source of truth: the next post uses NetBox or Nautobot to hold the intended state and feed Ansible, so the candidate config is generated rather than typed by hand.
Now live: NetBox as a source of truth for Ansible, with NetBox 4.7 driving the inventory and the push.