← Back to blogGuides

SPF Too Many DNS Lookups: Fixing the 10-Lookup PermError

Why SPF returns permerror after 10 DNS lookups, which mechanisms count, how to count your own record by hand, and the fixes that last longer than flattening.

Published: · 7 min read

An SPF record may cause at most ten DNS-querying terms to be evaluated per check. include, a, mx, exists, redirect and ptr count, and everything inside nested includes counts too. ip4, ip6 and all cost nothing. The eleventh lookup ends the evaluation with permerror, and a permerror is not a pass: for DMARC purposes SPF has simply not passed. The durable fix is to have fewer services in the record, not to hide them more cleverly.

The rule comes from RFC 7208, section 4.6.4.

What counts and what does not

Term Counts towards the 10? Note
include: Yes Plus every counting term inside the included record, recursively
a Yes Also a:host.example.com
mx Yes Has an extra limit of its own, see below
exists: Yes Mostly used with macros
redirect= Yes A modifier, but it triggers a lookup
ptr Yes Deprecated by the RFC itself; remove it
ip4:, ip6: No Literal addresses, no DNS needed
all No
exp= No Only fetched to build an explanation text after a fail

The limit applies to one SPF evaluation as a whole. The initial fetch of your own TXT record is not counted; everything that record then triggers is.

Two more limits in the same section

  • mx and ptr fan-out. Evaluating one mx mechanism must not lead to more than ten address lookups for the MX names it returns (the same cap applies to the names found by ptr). A domain with more than ten MX hosts produces permerror through this door even if the total term count is low.
  • Void lookups. A lookup that returns NXDOMAIN or an empty answer is a "void lookup". The RFC says receivers SHOULD allow no more than two per evaluation and return permerror beyond that. Two dead includes left over from cancelled services can be enough.

And a classic that is not about counting

Two TXT records starting with v=spf1 on the same name are also a permerror. It happens when a new vendor's guide says "add this SPF record" and someone does exactly that. Merge the mechanisms into one record.

Why the failures look intermittent

Permerror means "this record cannot be interpreted". What a receiver does with it is local policy: some treat it like a fail, others like neutral. DMARC only asks whether SPF produced an aligned pass, and permerror is not a pass. If your mail also carries an aligned DKIM signature, DMARC still passes and nobody notices the broken SPF record for months. Then a message without DKIM (a forgotten application server, a scanner) starts bouncing at one provider and arriving at another.

Mechanism order adds to the confusion. SPF evaluates left to right and stops at the first match. A sender that matches your second mechanism never reaches the eleventh lookup; a sender listed at the end of the record does. So the same record can pass for your mailbox provider and permerror for your invoicing tool.

Count your record by hand

Start with the record itself:

$ dig +short TXT example.com | grep spf1
"v=spf1 mx a include:_spf.mailprovider.example include:spf.newsletter.example include:spf.crm.example include:spf.helpdesk.example ip4:192.0.2.10 -all"

Then follow every include, and every include inside those:

$ dig +short TXT _spf.mailprovider.example
"v=spf1 include:_netblocks1.mailprovider.example include:_netblocks2.mailprovider.example include:_netblocks3.mailprovider.example ~all"

$ dig +short TXT _netblocks1.mailprovider.example
"v=spf1 ip4:198.51.100.0/24 ip6:2001:db8:10::/48 ~all"

Write down one line per counting term:

Term in example.com Own cost Nested cost Subtotal
mx 1 0 1
a 1 0 1
include:_spf.mailprovider.example 1 3 includes 4
include:spf.newsletter.example 1 1 include 2
include:spf.crm.example 1 1 include + 1 a 3
include:spf.helpdesk.example 1 0 1
ip4:192.0.2.10 0 0 0
Total 12

Twelve: permerror for any sender that is not matched early. Note that the all inside an included record does not end your evaluation; an include only matters when it produces a pass.

Fixes, in the order you should try them

1. Remove what you no longer use

Most records over the limit contain at least one service that was cancelled years ago. Each removed vendor typically frees one to four lookups. It also closes a hole: an include authorises the platform's sending addresses for your domain, and on shared infrastructure those addresses also carry other customers' mail.

2. Replace a and mx with addresses

mx and a are convenient defaults that many generators add. If your web server does not send mail, a is useless. If the hosts in your MX records do not send your outbound mail (usual with hosted mail, where the provider's include covers sending), mx is useless too. Where a server does send and its address is stable, write ip4:192.0.2.10 or ip6:2001:db8::25 instead: zero lookups.

3. Check whether the service uses your domain in the envelope at all

SPF checks the domain in the envelope sender (MAIL FROM, shown later as Return-Path), not the From header people see. Many email service providers use their own bounce domain there. In that case the receiver looks up their SPF record, never yours, and their include in your record costs lookups while doing nothing.

Open a message the service really sent and read the headers:

Return-Path: <bounce-7f3a@bounces.newsletter.example>
From: Example Shop <news@example.com>

Return-Path is not under example.com, so include:spf.newsletter.example in your apex record can go. What makes this mail pass DMARC is a DKIM signature with d=example.com, or a custom return-path (next step).

4. Give each sending service its own subdomain

The limit is per evaluation, and each evaluation starts at the envelope domain. Send newsletters from news.example.com and transactional mail from billing.example.com, and each name gets its own record and its own budget of ten:

news.example.com.     TXT  "v=spf1 include:spf.newsletter.example -all"
billing.example.com.  TXT  "v=spf1 include:spf.invoicing.example -all"

Most platforms implement this as a "custom return-path" or "custom bounce domain": you create a CNAME such as bounce.news.example.com pointing at the provider, and the SPF record lives on their side entirely. With relaxed alignment (the DMARC default) bounce.news.example.com aligns with a From address at example.com. SPF records are not inherited, so a subdomain without its own record has none.

5. Lean on aligned DKIM

DMARC needs one aligned pass, SPF or DKIM. For every service that can sign with your domain in d=, DKIM alone is enough, and it survives forwarding, which SPF does not (see why email forwarding breaks SPF). That is what makes step 3 safe.

6. Flattening, only with automation

"Flattening" replaces each include with the IP ranges it currently resolves to. The lookup count drops to zero and the record works today. The trouble is tomorrow: providers add and retire ranges without telling you, and the day they do, part of your legitimate mail starts failing SPF with a record that looks perfectly valid. If you flatten, it has to be a process that re-resolves the includes regularly and republishes the record, and you need to watch that process. A hand-flattened record is a delayed outage.

Flattened records also get long. One TXT character-string holds at most 255 bytes; longer records are split into several strings, which receivers concatenate without adding spaces, so a split in the wrong place glues two mechanisms together. Keep the complete answer small as well: responses beyond the traditional 512-byte UDP size depend on EDNS0 or TCP fallback, and the RFC advises staying under it.

7. Macros

SPF macros (exists:%{i}._spf.example.com) can collapse many senders into a single lookup. They need a DNS backend built for it and are hard to debug: not a first-line fix.

~all versus -all is unrelated to all of this: the qualifier on all decides what happens to non-matching senders and costs no lookup either way.

Common mistakes

  • Counting only the includes you can see in the top-level record and forgetting the nested ones.
  • Adding include: for a service whose Return-Path is on its own domain.
  • Leaving includes of cancelled services in place: they cost lookups and, once the vendor deletes the name, void lookups.
  • Publishing a second v=spf1 record instead of editing the first.
  • Splitting a long record into 255-byte strings and losing the space between two mechanisms at the join.

If you are building a record from scratch, the SPF generator assembles the syntax; the counting still has to be done against the live includes.

Check it with OrbitProbe

The OrbitProbe SPF checker looks up the SPF record of a domain and flags the problems that most often break it: more than one record, +all, a missing all mechanism and too many DNS lookups. Run it for the apex domain and then separately for each subdomain that sends mail, because every name is evaluated on its own. A DNS lookup shows what is published at that moment; it cannot show whether your mail is being accepted, so keep reading your DMARC reports as well. For the wider picture of how the four mail records depend on each other, see MX, SPF, DKIM and DMARC explained, and for a full delivery review the DNS checklist for mail going to spam.