Your SPF record can look perfect, list every service that sends mail for you, and still fail on every single message. If a DMARC report or a bounce shows spf=permerror, the usual reason is that the record needs more than 10 DNS lookups to evaluate, and receivers stop counting at 10.

The limit comes from RFC 7208, the SPF standard. It exists so that checking one email can't turn into hundreds of DNS queries. The catch is that you don't only pay for the lookups in your own record. Every include pulls in the vendor's record, and that record can have includes of its own. Here's how to count what yours really costs and how to trim it without breaking mail from the services you still use.

Where the error shows up

SPF too many DNS lookups is a quiet failure. Mail doesn't bounce on the spot. Instead you see some of these:

  • spf=permerror in the Authentication-Results header of a message you sent to an outside mailbox
  • DMARC aggregate reports where SPF fails for your own mail servers, even though their IPs are clearly listed
  • An online checker saying "too many DNS lookups" or "PermError"
  • Mail that started landing in spam right after you added a new tool to the record

A permerror isn't a pass, so as far as DMARC is concerned SPF failed. If DKIM still passes and is aligned, DMARC passes anyway, which is why the problem can sit there for months. Once you tighten DMARC, or DKIM breaks for one sender, those messages start getting rejected. If you're not sure how to read those reports, start with our guide on reading DMARC reports.

What counts toward the 10

Only terms that make the receiver ask DNS something count. That's include, a, mx, ptr, exists and the redirect modifier. Each costs one lookup, and every one of those inside an included record costs one too.

These are free: ip4, ip6, all and exp. You can list fifty IP ranges and pay nothing.

Diagram of what counts toward the SPF limit of 10: include, a, mx, ptr, exists and redirect each cost one DNS lookup, while ip4, ip6, all and exp are free, with an example record v=spf1 mx include:spf.example.net ip4:192.0.2.10 ~all that costs 4 lookups because the include pulls in two more includes

Two more rules sit next to the main one. An mx term can't point at a name with more than 10 MX hosts. And there's a limit of two "void" lookups, meaning queries that come back with no answer at all. A typo in an include, or an old vendor domain that no longer publishes SPF, eats one of those, and the record also fails with a permerror on its own, because an include must point at a real SPF record.

Receivers usually evaluate the record from left to right and stop at the first match, so in theory a sender listed early never hits the limit. Don't plan around that. Mail from a server that isn't in your record, including every forged message, walks the whole thing, and you want those to get a clean fail rather than a permerror.

Count it by hand once

It's worth doing this once with dig so the number makes sense. Start with your own record:

dig +short TXT example.com | grep spf1 

Then follow each include. This is real output for one popular sending service:

$ dig +short TXT sendgrid.net "v=spf1 ip4:167.89.0.0/17 ip4:208.117.48.0/20 ip4:50.31.32.0/19 ip4:198.37.144.0/20 ip4:198.21.0.0/21 ip4:192.254.112.0/20 ip4:168.245.0.0/17 ip4:149.72.0.0/16 ip4:159.183.0.0/16 ip4:134.128.64.0/18 ip4:159.26.176.0/20 include:ab.sendgrid.net ~all" 

Eleven ranges are free, but the include:ab.sendgrid.net at the end isn't. So include:sendgrid.net in your record costs two lookups, not one. Repeat that for every include and add it up. If dig's output format is unfamiliar, our walkthrough on reading dig output explains each part.

One detail that trips people up: a long TXT record comes back as several quoted strings, like "...ip4:62.253.2" "27.114...". Receivers glue those together with no space in between, so that's one address, 62.253.227.114, not two broken ones.

Count it with one command

Doing that by hand for eight vendors gets old fast, so we wrote a small read-only script that walks the whole tree the way a receiver does. It prints every lookup with a running total, the cost of each term in your record, and the void lookup count.

./email/spf-lookup-count.sh example.com ./email/spf-lookup-count.sh --doh example.com # ask 1.1.1.1 over HTTPS ./email/spf-lookup-count.sh --record "v=spf1 ..." example.com # test a draft 

Here's a real run against a draft record for a business that sends through a mailbox provider, a transactional mail service, a newsletter tool, a CRM, a help desk and its web host:

Terminal screenshot of spf-lookup-count.sh checking a draft SPF record for example.com with a, mx and six includes: the tree shows a running total that reaches 12, with the eleventh and twelfth lookups inside the web host include, a cost table per term, and the result FAIL, 12 DNS lookups over the limit of 10, exit code 2

Twelve lookups for eight terms. The web host's include alone costs three, because it includes two relays of its own. Nothing in that record is wrong on its face, and every term looks like one lookup when you read it. That's exactly why this error is so common.

Getting back under 10

Work through these in order. The first ones are free and safe, the last one needs care.

Checklist of five ways to get an SPF record back under 10 lookups, in order: remove senders you no longer use, check the Return-Path before keeping an include, swap a and mx for ip4 addresses, move bulk mail to a subdomain with its own record, and flatten only with a tool that refreshes the ranges automatically

Remove senders you no longer use. Old newsletter tools and trial CRMs stay in SPF for years. Ask around before deleting, but each dead include is pure cost.

Check which domain SPF is checked against. SPF looks at the envelope sender, the Return-Path, not the From address people see. Many bulk senders use their own bounce domain, or a subdomain of yours like bounces.example.com with its own SPF record. If that's the case for a service, its include on your root record does nothing. Open a message it sent, look at Return-Path and the spf= result, and only then remove it.

Swap a and mx for addresses. a and mx each cost a lookup just to say "the web server" and "the mail server". If those servers have fixed IPs, write ip4:192.0.2.10 instead. And if your outgoing mail already goes through the mailbox provider's include, mx is often redundant, because the MX hosts receive mail and don't send it.

Move a heavy sender to a subdomain. Marketing mail from news.example.com gets its own SPF record with its own budget of 10.

Flatten as a last resort. Flattening means replacing an include with the IP ranges it resolves to. It works today and breaks quietly the day the vendor adds a new range, so only do it with a tool that refreshes the list on a schedule, and never for a large provider that changes ranges often.

In the draft above, replacing a and mx with the server's IP and dropping the newsletter include (say it sends with its own Return-Path) brings the count to 9. That's legal, but close. One more vendor and it's back over.

Test before you publish

Because --record counts a draft without touching DNS, you can try a change first and publish only the version that passes. When you do publish, keep one SPF record per name. Two v=spf1 TXT records on the same name is its own permerror, and it's the most common mistake when a new vendor's setup guide says "add this record". Merge their include into the existing one instead. Our guide on keeping email out of spam covers the rest of the record set.

After the change, send a message to an outside mailbox and check that the header now says spf=pass. Lookups are cached, so give it the record's TTL before you judge.

The script also has a -q flag that prints one line, so you can run it from cron and catch the day a vendor adds a nested include and pushes you over. It's free in our server toolkit on GitLab: email/spf-lookup-count.sh. If you're setting up a new site and mailbox anyway, Pro Gineous has shared hosting at $29 for the first year with the code new-2026, valid until December 1, 2026.

Originally published on the Pro Gineous blog.