An SPF record may cost at most 10 DNS lookups while a receiver checks it. One more and the result is permerror. The record is then broken for every message, including mail from servers it does list (RFC 7208, section 4.6.4).
Online checkers count for you. Counting once by hand is still worth it, because it shows the thing that catches most people out: the record you published is only the top of a tree, and the receiver walks all of it.
You need dig. It ships with macOS. On Debian and Ubuntu it's in dnsutils, and on Fedora and RHEL in bind-utils.
What counts
Six terms make a receiver query DNS, and each costs one lookup: include, a, mx, ptr, exists and the redirect modifier. Lookups inside an included record count too, all the way down.
Free: ip4, ip6, all, and the exp modifier (it's only looked up later, to explain a failure).
The query for your own domain's TXT record is not one of the ten. The ten are what the record makes the receiver look up.
Step 1: read the record
dig +short TXT example.com
You get every TXT record on the name. The SPF one starts with v=spf1. There must be exactly one: two v=spf1 records on the same name is a permerror before anything is counted.
One trap: a long TXT record comes back as several quoted strings. Here is Freshdesk's US record as dig printed it on 2 October 2026 (shortened):
"v=spf1 ip4:34.198.193.174 ... ip4:44.192.35.0/24" " ip4:18.235.53.110 ip4:54.159.173.91 ~all"
SPF joins the strings with no space between them (RFC 7208, section 3.3). That is why the second string here starts with a space of its own. If you join them with a space, or drop one, you can read a different record from the one receivers see.
Step 2: follow one include
Take Mailgun's include, a common one, and follow it down. These are the records as they were on 2 October 2026:
$ dig +short TXT mailgun.org
"v=spf1 include:_spf.mailgun.org include:_spf.eu.mailgun.org -all"
$ dig +short TXT _spf.mailgun.org
"v=spf1 include:_spf1.mailgun.org include:_spf2.mailgun.org ~all"
$ dig +short TXT _spf1.mailgun.org
"v=spf1 ip4:159.135.224.0/20 ip4:69.72.32.0/20 ip4:204.220.90.0/23 ip4:204.220.92.0/22 ~all"
Count it:
include:mailgun.org 1 (the term in your record)
include:_spf.mailgun.org 2
include:_spf1.mailgun.org 3
include:_spf2.mailgun.org 4
include:_spf.eu.mailgun.org 5
One line in your record, five lookups. _spf1, _spf2 and the EU record list only ip4: ranges, so the tree stops there.
Note that the -all and ~all inside included records don't apply to your domain. An include only asks "does the sending IP match anything in there?".
Step 3: watch for duplicates
Freshdesk's global include is email.freshdesk.com:
$ dig +short TXT email.freshdesk.com
"v=spf1 include:sendgrid.net include:fdspfus.freshemail.io include:fdspfeuc.freshemail.io include:fdspfind.freshemail.io include:fdspfaus.freshemail.io ~all"
That is SendGrid (two lookups, because sendgrid.net includes ab.sendgrid.net) plus four regional records: seven in all. If your record also has include:sendgrid.net for your own SendGrid account, SendGrid is now in the tree twice, and both copies count. Nothing deduplicates them.
The script
Doing this by hand for a whole record gets old quickly, so here is the same walk as a script. It prints one line per lookup, indented to show which include each one sits under, and counts the lines:
#!/usr/bin/env bash
# spf-count: print every DNS lookup an SPF record costs, one per line, the way RFC 7208 counts them.
# Usage: ./spf-count.sh example.com (the record a domain publishes)
# ./spf-count.sh "v=spf1 include:... ~all" (a draft, before you publish it)
set -f # terms such as ?all must not be read as file name patterns
spf_record() {
# dig prints a long TXT record as "part one" "part two"; SPF joins the parts with no space.
dig +short TXT "$1" | sed -e 's/" "//g' -e 's/"//g' | grep -i '^v=spf1 '
}
walk_record() {
local record=$1 indent=$2 term lower
for term in $record; do
term=${term#[+~?-]} # drop the qualifier
lower=$(printf '%s' "$term" | tr '[:upper:]' '[:lower:]')
case $lower in
include:*) echo "${indent}${term}"; walk "${term#*:}" "$indent " ;;
redirect=*) echo "${indent}${term}"; walk "${term#*=}" "$indent " ;;
a|a:*|a/*|mx|mx:*|mx/*|ptr|ptr:*|exists:*) echo "${indent}${term}" ;;
esac # ip4, ip6, all and exp are free
done
}
walk() {
local domain=$1 indent=$2 record
if [ ${#indent} -gt 20 ]; then # ten levels deep: an include loop
echo "${indent}! stopped at $domain: includes nested ten deep, probably a loop"
return
fi
record=$(spf_record "$domain")
if [ -z "$record" ]; then
echo "${indent}! $domain has no SPF record: including it is a permerror"
elif [ "$(printf '%s\n' "$record" | wc -l)" -gt 1 ]; then
echo "${indent}! $domain has more than one SPF record: permerror"
else
walk_record "$record" "$indent"
fi
}
case $1 in
v=spf1*) walk_record "$1" "" ;;
*) walk "$1" "" ;;
esac | awk '{ print } !/^ *!/ { n++ } END { print "lookups: " n+0 " of 10" }'
The second form is the useful one. It counts a record before you publish it, so you can try changes without touching DNS. Here is a record of the kind that grows over a few years: Google Workspace, SendGrid, Mailgun, Mailchimp, Freshdesk, plus a and mx "just in case":
$ ./spf-count.sh "v=spf1 a mx include:_spf.google.com include:sendgrid.net include:mailgun.org include:servers.mcsv.net include:email.freshdesk.com ~all"
a
mx
include:_spf.google.com
include:sendgrid.net
include:ab.sendgrid.net
include:mailgun.org
include:_spf.mailgun.org
include:_spf1.mailgun.org
include:_spf2.mailgun.org
include:_spf.eu.mailgun.org
include:servers.mcsv.net
include:email.freshdesk.com
include:sendgrid.net
include:ab.sendgrid.net
include:fdspfus.freshemail.io
include:fdspfeuc.freshemail.io
include:fdspfind.freshemail.io
include:fdspfaus.freshemail.io
lookups: 18 of 10
Eighteen, from seven terms. Most of them are in two includes, and SendGrid appears twice.
What the script doesn't do
It's for seeing where the lookups go, not a full SPF validator. It doesn't:
-
count void lookups. RFC 7208 also allows at most two lookups that return nothing. The script only flags an include with no SPF record, which is a
permerroron its own. -
check the sub-limits. An
mxterm may look up at most ten mail server names.ptrhas a similar rule. -
expand macros. An
include:%{i}._spf.example.comis followed literally, so it will report no record. -
check syntax. A typo anywhere in the record makes the whole thing a
permerror, and the script will happily count around it.
Getting back under 10
Once you can see the tree, the fixes are usually obvious. Some includes belong to services you no longer use. Some belong to services that send with their own bounce domain, so their include on your record does nothing: Mailchimp's servers.mcsv.net is one. A global include can often be swapped for your region's: Freshdesk's US record costs one lookup instead of seven. a and mx can go when those servers don't send mail.
That example record goes from 18 lookups to 2, and every service that still sends mail keeps passing. I wrote up each step, with what 13 common includes cost as of 2 October 2026, here: SPF PermError: too many DNS lookups, and how to get under 10.
If you'd rather not run a script, the SPF checker does the same walk and also counts void lookups. It needs no account.
Disclosure: I build DNS Toolbox, which makes that checker. The script above is yours to copy, no strings.
Sources: RFC 7208, Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1: section 3.3 (multiple strings), 4.5 (one record), 4.6.4 (DNS lookup limits).
Top comments (0)