There are two ways to do DNS lookups in Python. The standard library gives you socket.getaddrinfo(), which uses your operating system's resolver and works fine when all you need is an IP address. For anything beyond basic A/AAAA resolution — querying MX records, TXT records, specific nameservers, or handling DNS errors properly — you need dnspython.
This guide covers both approaches. I start with the standard library for simple cases, then move into dnspython for everything else: all record types, custom nameservers, reverse DNS, async queries, caching, error handling, and a real-world propagation checker. If you want to understand the protocol fundamentals behind what these libraries do, see How DNS Queries Work.
Quick Start: socket.getaddrinfo()
Python's socket module can resolve domain names without any third-party dependencies. It delegates to the operating system's resolver, which means it respects /etc/hosts, your system DNS configuration, and any local caching your OS provides.
import socket
# Simple A record lookup (IPv4 only)
results = socket.getaddrinfo("example.com", None, socket.AF_INET)
for result in results:
print(result[4][0]) # IP address
# Get both IPv4 and IPv6
results = socket.getaddrinfo("example.com", None)
for result in results:
family = "IPv4" if result[0] == socket.AF_INET else "IPv6"
print(f"{family}: {result[4][0]}")
There is also socket.gethostbyname(), but it only returns a single IPv4 address and is considered legacy. Use getaddrinfo() instead.
When socket is enough: If you just need to resolve a hostname to an IP address — for example, before making an HTTP request or opening a socket connection — socket.getaddrinfo() does the job. It is fast, requires no installation, and works everywhere Python runs.
When socket is not enough: You cannot query MX, TXT, NS, SOA, SRV, CAA, or any other record type. You cannot specify which nameserver to query. You cannot inspect TTL values, set timeouts independently, or get detailed error information. You cannot do reverse DNS lookups without a separate call to gethostbyaddr(), which has its own quirks. For any of these, you need dnspython.
Installing dnspython
dnspython is the de facto standard library for DNS operations in Python. It has been actively maintained since 2001, supports every DNS record type, and is used by major projects including Ansible, Certbot, and various email authentication libraries.
pip install dnspython
The package name on PyPI is dnspython, but you import it as dns:
import dns.resolver
Basic Queries with dnspython
The simplest way to query DNS with dnspython is through dns.resolver.resolve():
import dns.resolver
# A record lookup
answers = dns.resolver.resolve("example.com", "A")
for rdata in answers:
print(rdata.address)
# Response metadata
print(f"TTL: {answers.rrset.ttl}")
print(f"Nameserver: {answers.nameserver}")
The resolve() function returns an Answer object that you can iterate over. Each item in the iteration is an rdata object whose attributes depend on the record type you queried.
Querying All Record Types
Here is how to query every common DNS record type with dnspython, along with the attributes available on each response.
A and AAAA Records
# IPv4
answers = dns.resolver.resolve("example.com", "A")
for rdata in answers:
print(rdata.address) # e.g., "93.184.216.34"
# IPv6
answers = dns.resolver.resolve("example.com", "AAAA")
for rdata in answers:
print(rdata.address) # e.g., "2606:2800:220:1:248:1893:25c8:1946"
MX Records
MX records include a preference (priority) value. Lower numbers mean higher priority.
answers = dns.resolver.resolve("example.com", "MX")
for rdata in sorted(answers, key=lambda r: r.preference):
print(f"Priority: {rdata.preference}, Server: {rdata.exchange}")
Sorting by preference ensures you see the highest-priority mail server first — which is the one email senders will try first.
TXT Records
TXT records are commonly used for SPF, DKIM, domain verification, and other metadata. A single TXT record can contain multiple strings (each up to 255 bytes), which you need to join together.
answers = dns.resolver.resolve("example.com", "TXT")
for rdata in answers:
# Join multi-part TXT strings
txt_value = "".join([s.decode() for s in rdata.strings])
print(txt_value)
# Check for SPF
if txt_value.startswith("v=spf1"):
print(f" SPF record found: {txt_value}")
NS Records
answers = dns.resolver.resolve("example.com", "NS")
for rdata in answers:
print(rdata.target) # e.g., "a.iana-servers.net."
SOA Records
The SOA (Start of Authority) record contains zone metadata including the primary nameserver, the responsible party's email, and serial/timing values.
answers = dns.resolver.resolve("example.com", "SOA")
for rdata in answers:
print(f"Primary NS: {rdata.mname}")
print(f"Admin email: {rdata.rname}")
print(f"Serial: {rdata.serial}")
print(f"Refresh: {rdata.refresh}s")
print(f"Retry: {rdata.retry}s")
print(f"Expire: {rdata.expire}s")
print(f"Minimum TTL: {rdata.minimum}s")
CNAME Records
answers = dns.resolver.resolve("www.example.com", "CNAME")
for rdata in answers:
print(rdata.target) # e.g., "example.com."
Note: if you query a CNAME for an A record, dnspython automatically follows the chain and returns the final A record. To see the CNAME itself, you must explicitly query for the CNAME type.
PTR Records
PTR records are used for reverse DNS. I cover the full workflow in the Reverse DNS section below.
answers = dns.resolver.resolve("8.8.8.8.in-addr.arpa.", "PTR")
for rdata in answers:
print(rdata.target) # e.g., "dns.google."
SRV Records
SRV records specify the host and port for specific services. They are commonly used for SIP, XMPP, LDAP, and other service discovery protocols.
answers = dns.resolver.resolve("_sip._tcp.example.com", "SRV")
for rdata in sorted(answers, key=lambda r: (r.priority, -r.weight)):
print(f"Priority: {rdata.priority}")
print(f"Weight: {rdata.weight}")
print(f"Port: {rdata.port}")
print(f"Target: {rdata.target}")
CAA Records
CAA (Certificate Authority Authorization) records specify which certificate authorities are allowed to issue certificates for a domain.
answers = dns.resolver.resolve("example.com", "CAA")
for rdata in answers:
print(f"Flags: {rdata.flags}")
print(f"Tag: {rdata.tag}")
print(f"Value: {rdata.value}")
Custom Nameservers
By default, dnspython uses your system's DNS resolver (from /etc/resolv.conf on Linux/macOS). To query a specific nameserver — for example, Google Public DNS, Cloudflare, or an authoritative server — create a custom Resolver instance:
import dns.resolver
resolver = dns.resolver.Resolver()
resolver.nameservers = ["8.8.8.8", "8.8.4.4"]
resolver.timeout = 5 # Timeout per query attempt (seconds)
resolver.lifetime = 10 # Total time allowed for all attempts
answers = resolver.resolve("example.com", "A")
for rdata in answers:
print(rdata.address)
Querying Authoritative Servers Directly
To query the authoritative nameserver for a domain (bypassing caches), first look up the NS records, then query one of those servers directly:
import dns.resolver
# Step 1: Find the authoritative nameservers
ns_answers = dns.resolver.resolve("example.com", "NS")
ns_host = str(ns_answers[0].target)
# Step 2: Resolve the nameserver's IP
ns_ip = dns.resolver.resolve(ns_host, "A")
# Step 3: Query the authoritative server directly
resolver = dns.resolver.Resolver()
resolver.nameservers = [str(ns_ip[0].address)]
answers = resolver.resolve("example.com", "A")
for rdata in answers:
print(f"Authoritative answer: {rdata.address}")
This is particularly useful when debugging propagation issues — you can verify what the authoritative server is actually returning versus what a caching resolver has stored. The DNS Inspector does exactly this, letting you query any nameserver directly from a browser.
Error Handling
DNS queries can fail in several well-defined ways. dnspython raises specific exceptions for each failure mode, which makes it straightforward to handle them:
import dns.resolver
from dns.resolver import NXDOMAIN, NoAnswer, NoNameservers, Timeout
def safe_lookup(domain: str, rdtype: str = "A"):
try:
answers = dns.resolver.resolve(domain, rdtype)
return [str(rdata) for rdata in answers]
except NXDOMAIN:
print(f"{domain}: domain does not exist")
except NoAnswer:
print(f"{domain}: no {rdtype} records found")
except NoNameservers:
print(f"{domain}: no nameservers available")
except Timeout:
print(f"{domain}: query timed out")
return []
Here is what each exception means:
- NXDOMAIN — the domain does not exist in DNS. The authoritative server confirmed there is no such name. See What Is NXDOMAIN? for a deep dive.
- NoAnswer — the domain exists, but there are no records of the type you queried. For example, querying MX for a domain that only has A records.
- NoNameservers — all nameservers failed to respond or returned errors. This often indicates a SERVFAIL condition where the server could not process the query.
- Timeout — the query exceeded the configured timeout. This could be a network issue or an unresponsive nameserver.
In production code, I always wrap DNS queries in a try/except block. DNS is an external dependency that can fail at any time, and unhandled exceptions will crash your application.
Reverse DNS
Reverse DNS maps an IP address back to a hostname. It uses PTR records in the special in-addr.arpa (IPv4) or ip6.arpa (IPv6) zones. dnspython provides a helper to construct the reverse name:
import dns.reversename
import dns.resolver
# Convert IP to reverse DNS name
addr = dns.reversename.from_address("8.8.8.8")
print(addr) # 8.8.8.8.in-addr.arpa.
# Look up the PTR record
answers = dns.resolver.resolve(addr, "PTR")
for rdata in answers:
print(rdata.target) # dns.google.
For IPv6, the process is the same — from_address() handles both IPv4 and IPv6:
addr = dns.reversename.from_address("2001:4860:4860::8888")
answers = dns.resolver.resolve(addr, "PTR")
for rdata in answers:
print(rdata.target) # dns.google.
You can also go the other direction — convert a reverse DNS name back to an IP:
ip = dns.reversename.to_address(dns.name.from_text("8.8.8.8.in-addr.arpa."))
print(ip) # 8.8.8.8
Keep in mind that not every IP address has a PTR record. Reverse DNS is optional and configured by the owner of the IP address block. If no PTR record exists, you will get an NXDOMAIN or NoAnswer exception.
Async DNS Queries
When you need to resolve many domains at once, sequential queries are slow. Each query involves a network round trip, and those add up quickly. dnspython provides an async resolver that works with Python's asyncio:
import asyncio
import dns.asyncresolver
async def lookup(domain: str, rdtype: str = "A"):
resolver = dns.asyncresolver.Resolver()
answers = await resolver.resolve(domain, rdtype)
return [rdata.address for rdata in answers]
async def multi_lookup(domains: list[str]):
tasks = [lookup(domain) for domain in domains]
results = await asyncio.gather(*tasks, return_exceptions=True)
for domain, result in zip(domains, results):
if isinstance(result, Exception):
print(f"{domain}: {result}")
else:
print(f"{domain}: {result}")
asyncio.run(multi_lookup(["google.com", "github.com", "example.com"]))
With asyncio.gather(), all three queries run concurrently. Instead of waiting 50ms + 50ms + 50ms sequentially (150ms total), you wait roughly 50ms for all three. The speedup scales linearly with the number of domains.
This pattern is especially useful for bulk operations like checking DNS records across a large domain list, verifying SPF/DKIM for multiple senders, or building monitoring tools that poll many domains on a schedule.
Caching
dnspython includes a built-in response cache that respects TTL values. By default, the global resolver already has caching enabled, but you can also configure it explicitly on a custom resolver:
import dns.resolver
resolver = dns.resolver.Resolver()
resolver.cache = dns.resolver.Cache()
# First query: hits the network
answers = resolver.resolve("example.com", "A")
# Second query (within TTL): served from cache
answers = resolver.resolve("example.com", "A")
The cache automatically evicts entries when their TTL expires. For long-running applications — monitoring scripts, web servers, background workers — this cache significantly reduces DNS traffic and improves response times.
If you need to bypass the cache (for example, to check whether a record has changed), create a resolver without a cache or use a fresh Resolver instance:
no_cache_resolver = dns.resolver.Resolver()
no_cache_resolver.cache = dns.resolver.Cache(cleaning_interval=0)
Real-World Example: Multi-Resolver Propagation Checker
Here is a practical script that checks whether a DNS record has propagated across major public resolvers. This is the kind of thing you would run after changing a DNS record to see if the new value has reached different nameservers.
import dns.resolver
RESOLVERS = {
"Google": "8.8.8.8",
"Cloudflare": "1.1.1.1",
"Quad9": "9.9.9.9",
"OpenDNS": "208.67.222.222",
}
def check_propagation(domain: str, rdtype: str = "A"):
results = {}
for name, server in RESOLVERS.items():
resolver = dns.resolver.Resolver()
resolver.nameservers = [server]
resolver.timeout = 5
resolver.lifetime = 5
try:
answers = resolver.resolve(domain, rdtype)
results[name] = sorted([str(rdata) for rdata in answers])
except Exception as e:
results[name] = [f"Error: {type(e).__name__}: {e}"]
return results
def print_propagation(domain: str, rdtype: str = "A"):
print(f"\nPropagation check: {domain} ({rdtype})\n{'-' * 50}")
results = check_propagation(domain, rdtype)
values = [tuple(v) for v in results.values() if not v[0].startswith("Error")]
consistent = len(set(values)) <= 1
for name, records in results.items():
print(f" {name:12s} -> {', '.join(records)}")
print(f"\n {'Consistent' if consistent else 'INCONSISTENT'} across resolvers")
print_propagation("example.com", "A")
print_propagation("example.com", "MX")
This is a simplified version of what I built for the Propagation Checker, which queries dozens of nameservers across multiple continents and shows results in real time. The Python version above is a good starting point for automation scripts, CI/CD pipelines, or monitoring systems where you need to verify DNS changes programmatically.
Under the Hood
Everything I have shown in this guide uses dns.resolver, which is a high-level interface. Under the hood, dnspython constructs binary DNS packets according to RFC 1035, sends them over UDP (falling back to TCP for large responses), parses the raw response bytes, and returns structured Python objects.
If you want to understand that lower layer — how DNS messages are structured, how to build a resolver from raw sockets, and what the wire protocol actually looks like — see Build a DNS Resolver from Scratch in Python. That article goes from zero to a working recursive resolver using nothing but socket and struct.
Putting It All Together
Between socket.getaddrinfo() and dnspython, Python has strong DNS support for any use case:
| Need | Tool |
|---|---|
| Resolve a hostname to an IP | socket.getaddrinfo() |
| Query MX, TXT, NS, SOA, SRV, CAA records | dns.resolver.resolve() |
| Query a specific nameserver | Custom Resolver with nameservers |
| Reverse DNS (IP to hostname) | dns.reversename + PTR query |
| Bulk/concurrent lookups | dns.asyncresolver |
| Production error handling | NXDOMAIN, NoAnswer, Timeout exceptions |
| Check propagation | Custom resolver per nameserver |
For interactive DNS exploration without writing code, try the DNS Inspector — it lets you query any record type against any nameserver directly from a browser, with the same level of detail you get from dnspython or dig.
For more DNS tools and techniques, see the dig command guide for command-line queries, DNS Troubleshooting Tools for a full diagnostic toolkit, and Understanding DNS Record Types for a reference on every record type covered in this guide.
