A receiving mail server that hits this limit stops evaluating and returns permerror. Under most policies permerror is treated as "SPF did not pass" — which means the record you carefully published is doing nothing at all, and if your DMARC policy depends on SPF alignment, mail that should pass starts failing. The record still looks correct in the DNS zone, which is why this one takes people so long to spot.
What exactly is the 10-lookup limit?
RFC 7208 section 4.6.4 says an SPF evaluation must not use more than 10 mechanisms and modifiers that cause DNS queries. The limit is on the *count of DNS-querying terms*, not on the number of queries a resolver makes, and it applies to the entire recursive evaluation — your record plus everything it pulls in, and everything those records pull in. There is no per-record budget. One include: that expands into six more includes has spent seven of your ten.
| Term | Counts toward the 10? | Notes |
|---|---|---|
include: | Yes — 1 each, plus everything inside it | The single biggest source of overruns |
a / a:host | Yes — 1 | Resolves A/AAAA for the domain or named host |
mx / mx:domain | Yes — 1 | The MX query is 1, but each returned MX name is then resolved; no more than 10 MX names may be processed |
ptr / ptr:domain | Yes — 1 | RFC 7208 says do not publish this. Remove it. |
exists: | Yes — 1 | Often macro-driven, e.g. exists:%{i}._spf.mta.salesforce.com |
redirect= | Yes — 1, plus the target record | Ignored entirely if an all mechanism is present |
ip4: / ip6: | No | Free. This is why flattening works. |
all | No | Just the default result |
exp= | No | Explanation text is not counted |
v=spf1 ... for the domain in the MAIL FROM.How do nested includes eat the budget?
The cost of an include: is never obvious from your own record, because the vendor controls what is on the other end and can change it without telling you. Two real examples, both verifiable right now with dig:
# Cheap: one flat record, no nesting. Cost = 1 lookup.
dig +short TXT _spf.google.com
# "v=spf1 ip4:74.125.0.0/16 ip4:209.85.128.0/17 ip6:2001:4860:4864::/56 ... ~all"
# Expensive: include:mailgun.org expands into four more records.
dig +short TXT mailgun.org | grep spf1
# "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"That second chain costs 5 of your 10 lookups: mailgun.org, _spf.mailgun.org, _spf.eu.mailgun.org, _spf1.mailgun.org, _spf2.mailgun.org. Meanwhile include:sendgrid.net costs 2, because sendgrid.net itself ends with include:ab.sendgrid.net. A record that looks like four vendors is often eight or nine lookups before you have added a and mx.
v=spf1 a mx include:_spf.google.com include:sendgrid.net include:mailgun.org include:_spf.salesforce.com ~allThe Salesforce entry is worth a second look: _spf.salesforce.com publishes v=spf1 exists:%{i}._spf.mta.salesforce.com -all, so the include costs 1 and the exists: inside it costs another 1. Macros are not free.
What is the void lookup limit?
There is a second, less well known cap in the same section of RFC 7208: void lookups. A void lookup is a DNS query made during SPF evaluation that returns NXDOMAIN, or NOERROR with zero answer records. The specification says implementations should limit these to two, and return permerror on the third. In practice this catches records that reference hostnames which no longer exist — a decommissioned a:mail.oldhost.example or an include: for a vendor whose SPF record was deleted when you cancelled the account.
- A dead
include:is a void lookup and still consumes one of the 10. mxon a domain with no MX records is a void lookup.- Void lookups are why a record can pass a syntax check and still permerror in production — the syntax is fine, the targets are gone.
- Run our SPF checker or a live
digon every hostname in the record; anything returning an empty answer needs removing.
How do you actually fix it?
1. Delete senders you no longer use
This is the fix that works and costs nothing. SPF records accumulate: a trial ESP from three years ago, a ticketing system that was replaced, a marketing tool nobody renewed. Before flattening anything, get a list of what genuinely sends as your domain. DMARC aggregate reports are the honest source — publish a record with a rua= address (the DMARC generator will build one) and read a week of reports before you touch SPF.
2. Do not add DKIM-only vendors to SPF
SPF authenticates the MAIL FROM / Return-Path domain, not the visible From: header. If a vendor sends with its own bounce domain and signs with a DKIM key on your domain, it does not belong in your SPF record at all — DKIM alignment alone satisfies DMARC. Plenty of overlong records are half full of vendors that never needed to be there. See how to find your DKIM selector to confirm a vendor is signing for you.
3. Move separate mail streams onto subdomains
Each domain gets its own 10-lookup budget, and DMARC alignment defaults to relaxed, meaning a subdomain of your organizational domain still aligns with a From: at the parent. Configure your ESP to use a custom Return-Path such as news.example.com or bounces.example.com, then publish a small SPF record on that subdomain with only that vendor in it. Your apex record shrinks to your corporate mail platform.
; apex — corporate mail only
example.com. TXT "v=spf1 include:_spf.google.com ~all"
; bulk marketing stream, own Return-Path, own budget
news.example.com. TXT "v=spf1 include:mailgun.org -all"
; transactional stream
mail.example.com. TXT "v=spf1 include:sendgrid.net -all"4. Flatten — and understand what you are signing up for
Flattening means resolving an include: chain and pasting the resulting ip4:/ip6: ranges into your record. It works, because IP mechanisms cost nothing. The cost is maintenance: the vendor can change ranges at any time, and when they do your flattened record silently starts failing SPF for real mail. That failure mode is worse than permerror, because nothing in your zone looks wrong. If you flatten, treat it as a monitored job — re-resolve the source records on a schedule, diff, and alert on change. Never flatten by hand once and forget it.
amazonses.com does exactly this today. Keep the whole answer comfortably under 512 bytes so it survives UDP without truncation.5. Drop ptr, and question a and mx
ptr is expensive, slow, and RFC 7208 explicitly advises against publishing it. Remove it. Then ask whether a and mx are earning their lookups: a authorizes your web server to send mail, which is only useful if it actually does, and mx authorizes your inbound mail servers, which for a hosted platform like Google Workspace or Microsoft 365 are already covered by the vendor include. Two lookups recovered for free is common. Confirm what your MX actually points at with the MX lookup first.
How do you verify the fix?
# 1. Read the published record exactly as a receiver sees it
dig +short TXT example.com | grep spf1
# 2. Confirm there is exactly ONE v=spf1 string (two = permerror, regardless of lookups)
dig +short TXT example.com | grep -c spf1
# 3. Walk each include target and count as you go
dig +short TXT _spf.google.com
dig +short TXT sendgrid.net | grep spf1
# 4. Confirm a subdomain stream resolves on its own
dig +short TXT news.example.comThen re-run the SPF checker, which counts the chain live rather than from a cached table, and send a real test message so you can read spf=pass in the header analyzer. Remember that resolvers will hold the old record for its TTL, so a fix is not visible everywhere immediately — check the DNS propagation checker if results disagree between test tools. Once SPF is under budget, revisit whether ~all or -all is right for you in SPF ~all vs -all.