There are seven mechanisms. Six of them can match a connecting IP address; the seventh matches everything. Evaluation is left to right, and the first match ends it — nothing after a match is ever considered.
The seven
| Mechanism | Matches when | DNS lookups |
|---|---|---|
ip4:198.51.100.7 | The connecting IP is that address, or in that CIDR range if one is given. | 0 |
ip6:2001:db8::/32 | Same, for IPv6. | 0 |
a | The connecting IP is one of the A/AAAA records of the current domain. With an argument (a:mail.example.com), of that name instead. | 1 |
mx | The connecting IP is an address of one of the domain’s MX hosts. | 1, plus one per MX host |
include:other.example | Evaluating that domain’s SPF record produces a pass. | 1, plus whatever that record costs |
exists:%{i}.check.example | The constructed name resolves to anything at all. | 1 |
ptr | Reverse DNS for the connecting IP resolves back to the domain. | Several |
Do not use ptr
RFC 7208 §5.5 says it "SHOULD NOT be used" — it is slow, it loads the reverse-DNS infrastructure of networks you do not own, and some receivers skip it entirely, which means a record relying on it behaves differently depending on who is evaluating it. If you see ptr in a record you inherited, replacing it is a straightforward improvement.
Order decides the answer
Because the first match wins, two records with identical terms in different orders can produce different results. Consider:
v=spf1 ip4:198.51.100.0/24 -all -> 198.51.100.7 passes v=spf1 -all ip4:198.51.100.0/24 -> 198.51.100.7 FAILS
In the second, -all matches everything, so it matches first and evaluation stops. The ip4 term after it is unreachable. This is not a hypothetical: it happens when someone appends a new sender to the end of a record without noticing where all is.
Tracing a record by hand
Take Cloudflare’s record and a hypothetical connection from 173.245.48.10:
"v=spf1 ip4:199.15.212.0/22 ip4:173.245.48.0/20 include:_spf.google.com ... -all"
ip4:199.15.212.0/22— is 173.245.48.10 in 199.15.212.0–199.15.215.255? No. Move on.ip4:173.245.48.0/20— is it in 173.245.48.0–173.245.63.255? Yes. Match. The qualifier is the implicit+, so the result is pass, and the six includes after it are never evaluated.
That last point has a practical consequence: putting your highest-volume sender early in the record saves receivers the DNS work of evaluating everything before it. It does not change any result, only the cost of reaching it.
exists, and macros
exists is the one mechanism that does something genuinely different: it constructs a DNS name from macros — %{i} for the connecting IP, %{d} for the domain, %{s} for the sender — and matches if that name resolves to anything.
v=spf1 exists:%{i}._spf.example.com -allWith a DNS server that can answer dynamically, this turns SPF from a static list into a query: the record asks "is this specific IP authorised right now?" and the answer can change without editing anything. It is how dynamic SPF implementations work, and it is the one approach that solves the lookup limit without the drawbacks of flattening. It also requires a DNS provider that supports it, which most do not.