dnspython
dnspython is a DNS toolkit that speaks the protocol directly instead of asking your operating system to resolve things for you. The high-level side is dns.resolver, where you ask for a name and record type and get back parsed answers with typed fields: MX records have preference and exchange, SRV records have port and target, SOA records have serial. The low-level side lets you build, sign, send, and parse individual DNS messages, load and edit zone files, run AXFR and IXFR transfers, and push dynamic updates with TSIG authentication. It supports UDP, TCP, DNS over TLS, DNS over HTTPS, and experimental DNS over QUIC, plus asyncio and trio variants of every query function. The import name is dns, not dnspython.
The complete DNS implementation for Python, and the only sensible choice once you need record types, specific nameservers, zone files, or dynamic updates. Use socket.getaddrinfo instead when all you want is a hostname resolved the same way the rest of your machine resolves it.
Use it if
- You need record types the standard library cannot give you: MX, TXT, SRV, CAA, DNSKEY, PTR, or SOA with their fields already parsed into attributes rather than raw bytes
- You need to query a specific nameserver rather than whatever the host is configured to use, which is how you check propagation, compare authoritative servers, or test a resolver you are running
- You are automating DNS records: dns.update with TSIG keys does RFC 2136 dynamic updates, and dns.zone reads, edits, and writes zone files
- You want DNS over HTTPS or DNS over TLS from Python without shelling out to a resolver binary
- You are writing mail or security tooling that needs SPF, DKIM, DMARC, or MTA-STS lookups, which are all TXT record parsing on specific subdomains
- You only need a hostname turned into an IP address: socket.getaddrinfo is in the standard library, respects /etc/hosts, nsswitch, mDNS, and search domains, and dnspython deliberately ignores all of them. The README says this outright, and a resolver that disagrees with the rest of your machine causes real confusion
- You expect caching: a fresh dns.resolver.Resolver has no cache, so a loop over a thousand names sends a thousand queries. You have to attach dns.resolver.Cache yourself and it is a simple in-process cache, not a real recursive resolver
- You want a small dependency footprint with all features on: the base install is pure standard library, but DNSSEC pulls cryptography, DNS over HTTPS pulls httpx, h2, and httpcore, trio support pulls trio, and IDNA pulls idna. Miss an extra and you get an ImportError at query time, not at install time
- You are doing high-volume concurrent lookups and want C-level speed: aiodns over c-ares handles tens of thousands of parallel queries with far less Python overhead, at the cost of dnspython's record-type coverage
- Your team does not know DNS: the API mirrors the protocol, so names are absolute or relative, rdata objects differ per type, and an empty answer, a missing name, and a dead nameserver are three distinct exceptions you must all handle
Setup reality
pip install dnspython gives you a pure-Python package with zero required dependencies on Python 3.10 or newer, and the import name is dns, which trips people up on first use and collides with any local dns.py file in your project. Every interesting feature is an extra: dnspython[doh] for DNS over HTTPS (httpx, h2, httpcore), dnspython[dnssec] for validation (cryptography 45+), dnspython[trio], dnspython[idna] for internationalized names, dnspython[doq] for the experimental QUIC transport, and dnspython[wmi] on Windows if registry scanning picks the wrong interface. You can combine them, for example dnspython[doh,dnssec]. Defaults matter: the resolver reads /etc/resolv.conf at construction, has a 2 second per-query timeout with a 5 second total lifetime, and does no caching at all unless you assign resolver.cache yourself.
Patterns
Look up A and AAAA recordsresolve-a-record
import dns.resolver
answers = dns.resolver.resolve("example.com", "A")
for rdata in answers:
print(rdata.address)
print("ttl:", answers.rrset.ttl)resolve() replaced query() in 2.0; query() still works but is deprecated. This does not read /etc/hosts, so a name you can ping may still raise NXDOMAIN here.
Handle the four ways a lookup failshandle-lookup-errors
import dns.resolver, dns.exception
try:
answers = dns.resolver.resolve(name, "MX")
except dns.resolver.NXDOMAIN:
return "name does not exist"
except dns.resolver.NoAnswer:
return "name exists, no MX records"
except dns.resolver.NoNameservers:
return "every nameserver refused or failed"
except dns.exception.Timeout:
return "timed out"NXDOMAIN and NoAnswer mean very different things and conflating them is the most common dnspython bug. Pass raise_on_no_answer=False if you would rather get an empty answer object back.
Read structured record typesmx-and-srv-records
import dns.resolver
mx = dns.resolver.resolve("example.com", "MX")
for r in sorted(mx, key=lambda r: r.preference):
print(r.preference, r.exchange.to_text())
srv = dns.resolver.resolve("_sip._tcp.example.com", "SRV")
for r in srv:
print(r.priority, r.weight, r.port, r.target)exchange and target are dns.name.Name objects with a trailing dot, not strings. Call .to_text() or str() before putting them in a URL or a config file.
Read TXT records such as SPF or DMARCtxt-records
import dns.resolver
for rdata in dns.resolver.resolve("_dmarc.example.com", "TXT"):
value = b"".join(rdata.strings).decode()
print(value)rdata.strings is a tuple of byte chunks because TXT records are split every 255 characters on the wire. Joining them is required for long DKIM keys, and forgetting to is why half-parsed DKIM records show up in production.
Query a chosen nameserver with your own timeoutsquery-specific-nameserver
import dns.resolver
res = dns.resolver.Resolver(configure=False)
res.nameservers = ["1.1.1.1", "8.8.8.8"]
res.timeout = 2.0 # per nameserver attempt
res.lifetime = 5.0 # total budget for the question
answers = res.resolve("example.com", "A")
# one-shot equivalent
answers = dns.resolver.resolve_at("1.1.1.1", "example.com", "A")configure=False skips reading /etc/resolv.conf, which you want when checking a specific server. timeout and lifetime are separate: a short timeout with a long lifetime retries, the reverse gives up early.
Reverse an IP address to a PTR namereverse-lookup
import dns.resolver, dns.reversename
rev = dns.reversename.from_address("8.8.8.8")
print(rev) # 8.8.8.8.in-addr.arpa.
name = dns.resolver.resolve(rev, "PTR")[0].target.to_text()
print(name)from_address handles IPv6 nibble format too. A PTR record is not proof of ownership, so do not use reverse lookups for authentication.
Resolve concurrently with asyncioasync-resolution
import asyncio
import dns.asyncresolver
async def lookup(name):
try:
ans = await dns.asyncresolver.resolve(name, "A")
return name, [r.address for r in ans]
except Exception as exc:
return name, exc
results = asyncio.run(asyncio.gather(*(lookup(n) for n in names)))dns.asyncresolver mirrors dns.resolver, and the backend is chosen from the running event loop, so the same code works under trio if you installed dnspython[trio]. Concurrency is still one Python coroutine per query, so cap it with a semaphore at a few hundred.
Query over DNS over HTTPSdns-over-https
import dns.message, dns.query
q = dns.message.make_query("example.com", "A")
resp = dns.query.https(q, "https://cloudflare-dns.com/dns-query", timeout=5)
for rrset in resp.answer:
print(rrset.to_text())Needs pip install dnspython[doh]; without it you get an ImportError for httpx at call time, not at install. dns.query.tls() is the equivalent for DNS over TLS on port 853.
Send a hand-built message over UDP with TCP fallbackraw-message-query
import dns.message, dns.query, dns.flags
q = dns.message.make_query("example.com", "NS")
q.flags &= ~dns.flags.RD # do not recurse, ask authoritatively
resp, used_tcp = dns.query.udp_with_fallback(q, "198.51.100.53", timeout=3)
print(resp.rcode(), used_tcp)udp_with_fallback retries over TCP when the answer comes back truncated, which is what any real client must do. The raw layer never raises NXDOMAIN; you read resp.rcode() yourself.
Pull a zone with AXFR and iterate the recordszone-transfer
import dns.zone, dns.query, dns.rdatatype
zone = dns.zone.from_xfr(dns.query.xfr("198.51.100.53", "example.com"))
for name, node in zone.nodes.items():
for rdataset in node.rdatasets:
print(name, rdataset.ttl, dns.rdatatype.to_text(rdataset.rdtype))
zone.to_file("example.com.zone")Almost every public nameserver refuses AXFR, so expect a refused transfer unless you control the server or are on its allow list. Use dns.query.inbound_xfr for incremental IXFR against a zone you already have.
Add a record with an authenticated dynamic updatedynamic-update
import dns.update, dns.query, dns.tsigkeyring, dns.tsig
keyring = dns.tsigkeyring.from_text({"update-key.": "base64secret=="})
upd = dns.update.Update("example.com", keyring=keyring,
keyalgorithm=dns.tsig.HMAC_SHA256)
upd.replace("www", 300, "A", "203.0.113.10")
response = dns.query.tcp(upd, "198.51.100.53", timeout=10)
print(dns.rcode.to_text(response.rcode()))replace overwrites the whole rrset, add appends to it, and delete removes. Send updates over TCP: a rejected update returns a rcode such as NOTAUTH rather than raising, so check it.
Validate a DNSSEC signaturevalidate-dnssec
import dns.dnssec, dns.message, dns.name, dns.query, dns.rdatatype
name = dns.name.from_text("example.com")
q = dns.message.make_query(name, dns.rdatatype.DNSKEY, want_dnssec=True)
resp = dns.query.udp(q, "8.8.8.8", timeout=5)
rrset, rrsig = resp.answer
dns.dnssec.validate(rrset, rrsig, {name: rrset}) # raises on failureRequires pip install dnspython[dnssec]. This checks one signature against the keys you passed; it is not chain-of-trust validation up to the root, which you still have to build or delegate to a validating resolver.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| aiodns | PyPI | You need thousands of concurrent lookups in asyncio and can live with c-ares limits; it is much faster per query but covers fewer record types. |
| dnslib | PyPI | You are building a DNS server or proxy and mainly need to encode and decode wire-format packets rather than resolve names. |
| async-dns | PyPI | You want a small pure-Python asyncio client and server in one package and do not need DNSSEC, zone files, or dynamic updates. |