Build a DNS, DHCP, and IPAM Drift Report with Python
An IPAM drift report can highlight stale records before they cause conflicts or failed lookups. This tutorial compares read-only CSV exports from three sources; it does not connect to infrastructure or change DNS, DHCP, or IPAM data.
Use exports generated through your approved process, normalize each to the same columns, and protect the report because it contains internal network inventory.
Normalize Each Source to Address and Hostname Columns
Input DataCreate one CSV per source with address and hostname headers. Export in-scope records consistently and document whether reservations, leases, aliases, and retired IPAM entries are included.
address,hostname10.20.40.15,app01.corp.example10.20.40.16,app02.corp.example⯠View Expected Console Output
ipam.csvdns.csvdhcp.csv
Figure 1: Review proposed drift findings before updating any source of truth.
Compare Records with Python's Standard Library
Python ScriptSave this as compare_ipam.py. It canonicalizes addresses and hostnames, reports cross-source mismatches, repeated CSV rows, and an address mapped to multiple hostnames within one source.
import csvimport ipaddressfrom collections import Counter, defaultdictfrom pathlib import Path
def load_records(path): records = defaultdict(Counter) with Path(path).open(newline="", encoding="utf-8-sig") as handle: for row in csv.DictReader(handle): address = str(ipaddress.ip_address(row["address"].strip())) hostname = row["hostname"].strip().rstrip(".").casefold() if not hostname: raise ValueError(f"empty hostname in {path} for {address}") records[address][hostname] += 1 return records
sources = { "IPAM": load_records("ipam.csv"), "DNS": load_records("dns.csv"), "DHCP": load_records("dhcp.csv"),}addresses = set().union(*(set(records) for records in sources.values()))
print(f"{'Address':15} {'IPAM':24} {'DNS':24} DHCP")
def display(records): return ",".join( f"{hostname}(x{count})" if count > 1 else hostname for hostname, count in sorted(records.items()) ) or "-"
for address in sorted(addresses, key=ipaddress.ip_address): values = {name: records.get(address, Counter()) for name, records in sources.items()} hostname_sets = {name: set(items) for name, items in values.items()} present = {tuple(sorted(items)) for items in hostname_sets.values() if items} mismatched = len(present) > 1 or any(not items for items in hostname_sets.values()) duplicate_rows = any( count > 1 for records in values.values() for count in records.values() ) multi_name_assignment = any(len(items) > 1 for items in hostname_sets.values()) if mismatched or duplicate_rows or multi_name_assignment: print( f"{address:15} " f"{display(values['IPAM']):24} " f"{display(values['DNS']):24} " f"{display(values['DHCP'])}" )⯠View Expected Console Output
Address IPAM DNS DHCP10.20.40.15 app01.corp.example app01.corp.example -10.20.40.16 app02.corp.example old-app02.corp.example app02.corp.exampleCompare the Three Read-Only Exports
ReportRun from the directory containing the three approved exports. The report includes addresses missing from a source, cross-source hostname differences, repeated rows marked with a count such as (x2), and addresses mapped to multiple names within one source.
python3 compare_ipam.py⯠View Expected Console Output
Address IPAM DNS DHCP10.20.40.15 app01.corp.example app01.corp.example -10.20.40.16 app02.corp.example old-app02.corp.example app02.corp.exampleConfirm Export Freshness and Record Ownership
Operational Follow-upCheck export times, lease state, DNS record type, aliases, and IPAM lifecycle status. Route confirmed mismatches to the source owner; update records only through the normal change process.
address | source exports | owner | verified finding | approved change reference⯠View Expected Console Output
Static exports compared. No network queries or infrastructure changes were made.