Mail to one big provider starts sliding into spam. You run dig and the SPF record looks fine — a tidy line of five includes ending in ~all, nothing obviously broken. Then you paste the domain into an SPF validator and it comes back with the real problem:
PermError: too many DNS lookups (11)That single error is worse than it looks. When SPF hits PermError, most receivers evaluate it as if you published no SPF record at all — none or neutral. Under DMARC, that quietly removes one of your two alignment paths, leaving you leaning entirely on DKIM. One rotated signing key or one forwarded message later, DMARC fails, and mail that used to pass starts bouncing or landing in the junk folder. The record still "looks right" in dig, which is exactly why this bug survives for months.
The rule everyone gets wrong
RFC 7208 §4.6.4 is blunt: an SPF evaluation may perform at most 10 DNS-querying lookups. Cross that line and the result is PermError. The part people miss is that the budget is total, across the entire recursive evaluation — not per record, not per include.
The mechanisms and modifiers that each cost one lookup:
includeamxptrexists- the
redirect=modifier
What is free and never counts against the budget:
ip4ip6allexp
So a record with fifty ip4: entries costs zero lookups, while a record with eleven bare includes is dead on arrival. Three more traps are worth knowing:
- `mx` fans out. Each
mxcosts one lookup for the MX set, then resolves the A/AAAA records behind every host — and RFC 7208 caps it at 10 MX records, throwing PermError past that. A domain with a sprawling MX set can trip that sub-limit on its own. - The void-lookup limit. Separate from the 10-lookup cap, RFC 7208 allows at most 2 "void" lookups — queries returning NXDOMAIN or NODATA — before it throws PermError. A dead include that no longer resolves doesn't just waste a slot; two of them and you're done.
- `ptr` is dead. RFC 7208 §5.5 says
ptrSHOULD NOT be used. It's slow, unreliable, and some receivers ignore it entirely. If it's in your record, delete it today.
One more constraint: each TXT string maxes out at 255 characters, and you want the whole record to fit in a single 512-byte UDP response so resolvers don't fall back to TCP. A giant flattened record can trip this even when the lookup count is zero.
And the fact that makes half the fixes below possible: SPF only ever checks the MAIL FROM / Return-Path domain (and HELO) — never the visible From: header. That address is DMARC's concern. This is why a sending subdomain buys you real headroom instead of just moving the problem around.
Audit first: count what actually resolves
The core mistake is counting the includes you can see. Five includes does not mean five lookups — it means five *entry points* into trees you don't control. include:_spf.google.com alone expands into three or four sub-lookups because Google nests _netblocks, _netblocks2, and _netblocks3 behind it.
Start by pulling the live record:
dig +short TXT evilmail.pro | grep 'v=spf1'Then follow a single include one level down to see the nesting:
dig +short TXT _spf.google.com
# "v=spf1 include:_netblocks.google.com include:_netblocks2.google.com include:_netblocks3.google.com ~all"There's your +3, hiding inside one line. To sketch the fan-out quickly, pull the top-level includes and loop:
dig +short TXT evilmail.pro | tr ' ' '\n' | grep '^include:' \
| while read inc; do echo "== $inc =="; dig +short TXT "${inc#include:}"; doneEyeballing that tree is error-prone. Get an authoritative machine count from a real SPF library — pyspf implements RFC 7208 including the lookup and void counters, so it returns permerror exactly when a receiver would:
python3 -c "import spf; print(spf.check2(i='1.2.3.4', s='[email protected]', h='mail.evilmail.pro'))"
# ('permerror', 'Too many DNS lookups')Stack the common enterprise five — Google (≈3-4) + Outlook (≈2-3) + SendGrid (≈1-2) + Mailchimp (≈1) + Salesforce (≈1) — and you are already brushing the ceiling before adding your own mx or a monitoring vendor. That's the bloated starting point almost every mid-size org lands in:
v=spf1 include:_spf.google.com include:spf.protection.outlook.com include:sendgrid.net include:servers.mcsv.net include:_spf.salesforce.com ~allFix it without flattening (do these first)
Flattening is where most guides jump straight to, and it's the wrong first move. Work this list in order — it's sorted by durability, cheapest and most permanent first.
1. Delete dead senders. That ESP you trialed in 2023 is almost certainly still in the record, still costing a lookup, and if its include domain has since gone away it's also burning a void lookup. Cross-reference every include against what's actually sending today. This alone routinely drops the count by two or three.
2. Split bulk and transactional mail onto a sending subdomain. Because SPF checks the MAIL FROM / Return-Path domain, marketing blasts sent with a bounce domain of mktg.evilmail.pro are evaluated against *that* domain's SPF — not your apex. Move the heavy ESP includes there and keep the apex lean:
; apex — only what the apex actually sends
evilmail.pro. TXT "v=spf1 include:_spf.google.com -all"
; dedicated bulk/return-path domain — its own 10-lookup budget
mktg.evilmail.pro. TXT "v=spf1 include:sendgrid.net include:servers.mcsv.net -all"This is free headroom: each domain gets its own independent 10-lookup budget. Point your ESP's Return-Path/bounce domain at the subdomain and it just works.
3. Replace `include:` with literal `ip4`/`ip6` — but only for providers who publish stable, documented ranges. Some infrastructure vendors commit to fixed netblocks. Inlining those as ip4: costs zero lookups and never goes stale. Do not do this for Google, Microsoft, or Amazon SES, whose ranges move without notice.
4. Consolidate with `redirect=` only when you fully own the target. And know the tradeoff: redirect costs a lookup exactly like include. It saves you nothing on the budget — it's an organizational tool for stitching together domains you control, not a way to shrink the count.
Flattening: the last resort, done right
Flattening means recursively resolving every include down to raw ip4/ip6 CIDRs and inlining them, so the lookup count drops to effectively zero:
v=spf1 ip4:209.85.128.0/17 ip4:35.190.247.0/24 ip6:2607:f8b0::/32 ... -allElegant on paper. The catch: you've frozen a moving target. Google, Amazon SES, and Microsoft rotate their sending ranges constantly and never notify you. The day Google adds a /24, your flattened record no longer lists it, and legitimate mail from that block fails SPF — silently, with no error in your logs, because the record is technically valid. You didn't fix a problem; you scheduled an outage for an unknown future date.
So flattening is only acceptable with automation behind it. A flattened record without a scheduled re-resolve and drift alerting is not a solution — it's a time bomb. The loop you need: a scheduled job re-expands every source include to current CIDRs, diffs the result against the live TXT, and if they differ, pushes the new record via your DNS provider's API and pages a human.
If you want dynamism without freezing anything, SPF macros are the advanced route: exists:%{i}._spf.evilmail.pro lets you answer per-source-IP with a small custom DNS responder that returns the right result at query time. It keeps the record tiny and always-current, at the cost of running and maintaining that responder. Most teams don't need it — but it beats a hand-maintained flattened record.
Verify and monitor
Prove the fix against a real receiver instead of trusting a web checker. swaks sends a live message and shows you exactly what the receiving side computed:
swaks --to [email protected] --from [email protected] --server smtp.evilmail.proThen read the delivered message's headers. You want to see, from the *receiver*:
Received-SPF: pass (google.com: domain of [email protected] designates ...)
Authentication-Results: mx.google.com; spf=pass (...) [email protected]Once you're confident the record is complete and stable, tighten the qualifier from ~all (softfail) to -all (hardfail). Softfail tells receivers "probably not us, but don't reject"; hardfail is an unambiguous "if it's not on this list, it's forged," and it's what you want backing a p=reject DMARC policy.
Then monitor continuously. The regression you're guarding against isn't dramatic — it's someone in marketing adding a sixth ESP that pushes you from 9 to 11 lookups on a Tuesday. Schedule the pyspf count against your live record and alert when it exceeds a threshold; 8 is a sane ceiling, leaving slack for one more sender before you overflow. Catch the breach the hour it happens, not the week sales files a ticket about deliverability.
Pre-flight and ongoing checklist
- Count the recursive expansion, not the includes — use pyspf/
check2, not your eyes. - Delete every include with no active sender behind it — dead ones also burn void lookups.
- Kill `ptr` entirely (RFC 7208 §5.5) and audit any
mxthat fans out widely. - Move bulk/transactional mail to a sending subdomain with its own SPF and its own 10-lookup budget.
- Inline `ip4`/`ip6` only for providers with stable, documented ranges — never Google/Microsoft/SES.
- Keep each string under 255 chars and the record inside 512 bytes to avoid TCP fallback.
- If you flatten, ship the automation with it — re-resolve on a schedule, diff, publish via API, page on drift.
- Tighten to `-all` once the record is proven complete.


