<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Usman Khurshid</title>
    <description>The latest articles on DEV Community by Usman Khurshid (@usmank11).</description>
    <link>https://dev.to/usmank11</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4165282%2F69e2d2aa-f2a9-4648-834a-f03a40be6f28.png</url>
      <title>DEV Community: Usman Khurshid</title>
      <link>https://dev.to/usmank11</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/usmank11"/>
    <language>en</language>
    <item>
      <title>Counting SPF DNS lookups by hand (and a 45-line script that does it for you)</title>
      <dc:creator>Usman Khurshid</dc:creator>
      <pubDate>Tue, 06 Oct 2026 04:17:30 +0000</pubDate>
      <link>https://dev.to/usmank11/counting-spf-dns-lookups-by-hand-and-a-45-line-script-that-does-it-for-you-2k3b</link>
      <guid>https://dev.to/usmank11/counting-spf-dns-lookups-by-hand-and-a-45-line-script-that-does-it-for-you-2k3b</guid>
      <description>&lt;p&gt;An SPF record may cost at most &lt;strong&gt;10 DNS lookups&lt;/strong&gt; while a receiver checks it. One more and the result is &lt;code&gt;permerror&lt;/code&gt;. The record is then broken for every message, including mail from servers it does list (RFC 7208, section 4.6.4).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You need &lt;code&gt;dig&lt;/code&gt;. It ships with macOS. On Debian and Ubuntu it's in &lt;code&gt;dnsutils&lt;/code&gt;, and on Fedora and RHEL in &lt;code&gt;bind-utils&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts
&lt;/h2&gt;

&lt;p&gt;Six terms make a receiver query DNS, and each costs one lookup: &lt;code&gt;include&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt;, &lt;code&gt;ptr&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt; and the &lt;code&gt;redirect&lt;/code&gt; modifier. &lt;strong&gt;Lookups inside an included record count too&lt;/strong&gt;, all the way down.&lt;/p&gt;

&lt;p&gt;Free: &lt;code&gt;ip4&lt;/code&gt;, &lt;code&gt;ip6&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt;, and the &lt;code&gt;exp&lt;/code&gt; modifier (it's only looked up later, to explain a failure).&lt;/p&gt;

&lt;p&gt;The query for your own domain's TXT record is not one of the ten. The ten are what the record &lt;em&gt;makes&lt;/em&gt; the receiver look up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: read the record
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get every TXT record on the name. The SPF one starts with &lt;code&gt;v=spf1&lt;/code&gt;. There must be exactly one: two &lt;code&gt;v=spf1&lt;/code&gt; records on the same name is a &lt;code&gt;permerror&lt;/code&gt; before anything is counted.&lt;/p&gt;

&lt;p&gt;One trap: a long TXT record comes back as several quoted strings. Here is Freshdesk's US record as &lt;code&gt;dig&lt;/code&gt; printed it on 2 October 2026 (shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;"v=spf1 ip4&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt;34.198.193.174 ... ip4:44.192.35.0/24" " ip4:18.235.53.110 ip4:54.159.173.91 ~all"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SPF joins the strings &lt;strong&gt;with no space between them&lt;/strong&gt; (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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: follow one include
&lt;/h2&gt;

&lt;p&gt;Take Mailgun's include, a common one, and follow it down. These are the records as they were on 2 October 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short TXT mailgun.org
&lt;span class="s2"&gt;"v=spf1 include:_spf.mailgun.org include:_spf.eu.mailgun.org -all"&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short TXT _spf.mailgun.org
&lt;span class="s2"&gt;"v=spf1 include:_spf1.mailgun.org include:_spf2.mailgun.org ~all"&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short TXT _spf1.mailgun.org
&lt;span class="s2"&gt;"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"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Count it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line in your record, &lt;strong&gt;five lookups&lt;/strong&gt;. &lt;code&gt;_spf1&lt;/code&gt;, &lt;code&gt;_spf2&lt;/code&gt; and the EU record list only &lt;code&gt;ip4:&lt;/code&gt; ranges, so the tree stops there.&lt;/p&gt;

&lt;p&gt;Note that the &lt;code&gt;-all&lt;/code&gt; and &lt;code&gt;~all&lt;/code&gt; inside included records don't apply to your domain. An include only asks "does the sending IP match anything in there?".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: watch for duplicates
&lt;/h2&gt;

&lt;p&gt;Freshdesk's global include is &lt;code&gt;email.freshdesk.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dig +short TXT email.freshdesk.com
&lt;span class="s2"&gt;"v=spf1 include:sendgrid.net include:fdspfus.freshemail.io include:fdspfeuc.freshemail.io include:fdspfind.freshemail.io include:fdspfaus.freshemail.io ~all"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is SendGrid (two lookups, because &lt;code&gt;sendgrid.net&lt;/code&gt; includes &lt;code&gt;ab.sendgrid.net&lt;/code&gt;) plus four regional records: seven in all. If your record &lt;em&gt;also&lt;/em&gt; has &lt;code&gt;include:sendgrid.net&lt;/code&gt; for your own SendGrid account, SendGrid is now in the tree twice, &lt;strong&gt;and both copies count&lt;/strong&gt;. Nothing deduplicates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# spf-count: print every DNS lookup an SPF record costs, one per line, the way RFC 7208 counts them.&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./spf-count.sh example.com             (the record a domain publishes)&lt;/span&gt;
&lt;span class="c"&gt;#        ./spf-count.sh "v=spf1 include:... ~all"  (a draft, before you publish it)&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;   &lt;span class="c"&gt;# terms such as ?all must not be read as file name patterns&lt;/span&gt;

spf_record&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# dig prints a long TXT record as "part one" "part two"; SPF joins the parts with no space.&lt;/span&gt;
  dig +short TXT &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/" "//g'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/"//g'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^v=spf1 '&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

walk_record&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;record&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt; term lower
  &lt;span class="k"&gt;for &lt;/span&gt;term &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="p"&gt;#[+~?-]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;                         &lt;span class="c"&gt;# drop the qualifier&lt;/span&gt;
    &lt;span class="nv"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'[:upper:]'&lt;/span&gt; &lt;span class="s1"&gt;'[:lower:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$lower&lt;/span&gt; &lt;span class="k"&gt;in
      &lt;/span&gt;include:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; walk &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="p"&gt;#*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$indent&lt;/span&gt;&lt;span class="s2"&gt;  "&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
      &lt;span class="nv"&gt;redirect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; walk &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="p"&gt;#*=&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$indent&lt;/span&gt;&lt;span class="s2"&gt;  "&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
      a|a:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;a/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;mx|mx:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;mx/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;ptr|ptr:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;exists:&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;term&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;                                        &lt;span class="c"&gt;# ip4, ip6, all and exp are free&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

walk&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt; record
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 20 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;              &lt;span class="c"&gt;# ten levels deep: an include loop&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;! stopped at &lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;: includes nested ten deep, probably a loop"&lt;/span&gt;
    &lt;span class="k"&gt;return
  fi
  &lt;/span&gt;&lt;span class="nv"&gt;record&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;spf_record &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;! &lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt; has no SPF record: including it is a permerror"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;indent&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;! &lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt; has more than one SPF record: permerror"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;walk_record &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$indent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;&lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;spf1&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; walk_record &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       walk &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{ print } !/^ *!/ { n++ } END { print "lookups: " n+0 " of 10" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second form is the useful one. It counts a record &lt;strong&gt;before&lt;/strong&gt; 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 &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; "just in case":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./spf-count.sh &lt;span class="s2"&gt;"v=spf1 a mx include:_spf.google.com include:sendgrid.net include:mailgun.org include:servers.mcsv.net include:email.freshdesk.com ~all"&lt;/span&gt;
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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen, from seven terms. Most of them are in two includes, and SendGrid appears twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the script doesn't do
&lt;/h2&gt;

&lt;p&gt;It's for seeing where the lookups go, not a full SPF validator. It doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;count void lookups.&lt;/strong&gt; RFC 7208 also allows at most two lookups that return nothing. The script only flags an include with no SPF record, which is a &lt;code&gt;permerror&lt;/code&gt; on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;check the sub-limits.&lt;/strong&gt; An &lt;code&gt;mx&lt;/code&gt; term may look up at most ten mail server names. &lt;code&gt;ptr&lt;/code&gt; has a similar rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expand macros.&lt;/strong&gt; An &lt;code&gt;include:%{i}._spf.example.com&lt;/code&gt; is followed literally, so it will report no record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;check syntax.&lt;/strong&gt; A typo anywhere in the record makes the whole thing a &lt;code&gt;permerror&lt;/code&gt;, and the script will happily count around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting back under 10
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;servers.mcsv.net&lt;/code&gt; is one. A global include can often be swapped for your region's: Freshdesk's US record costs one lookup instead of seven. &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; can go when those servers don't send mail.&lt;/p&gt;

&lt;p&gt;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: &lt;a href="https://dnstoolbox.app/spf-permerror-too-many-dns-lookups/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=oct26&amp;amp;utm_content=20261002-counting-spf-lookups" rel="noopener noreferrer"&gt;SPF PermError: too many DNS lookups, and how to get under 10&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather not run a script, the &lt;a href="https://dnstoolbox.app/tools/spf-checker/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=oct26&amp;amp;utm_content=20261002-counting-spf-lookups" rel="noopener noreferrer"&gt;SPF checker&lt;/a&gt; does the same walk and also counts void lookups. It needs no account.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I build DNS Toolbox, which makes that checker. The script above is yours to copy, no strings.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; RFC 7208, &lt;a href="https://www.rfc-editor.org/rfc/rfc7208" rel="noopener noreferrer"&gt;Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1&lt;/a&gt;: section 3.3 (multiple strings), 4.5 (one record), 4.6.4 (DNS lookup limits).&lt;/p&gt;

</description>
      <category>dns</category>
      <category>email</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
  </channel>
</rss>
