---
title: "SPF Record Syntax: The Complete Reference (RFC 7208)"
description: "Every SPF record syntax element in one reference: mechanisms, qualifiers, modifiers, macros, and DNS limits — with RFC 7208 section cites and real examples."
publishedAt: 2026-08-03
tags: ["spf", "spf-record-syntax", "spf-mechanisms", "spf-macros", "rfc-7208", "dns", "email-authentication"]
faq:
  - question: "Can a domain have two SPF records?"
    answer: "No. RFC 7208 permits exactly one SPF record per domain name (Section 3.2), and when record selection finds more than one v=spf1 TXT record, the result is PermError (Section 4.5) — authentication fails for every message. Merge the mechanisms from both records into a single v=spf1 record instead."
  - question: "What is the difference between include and redirect in SPF?"
    answer: "include: matches only when the referenced record returns Pass, and evaluation continues if it doesn't (Section 5.2). redirect= applies only after every mechanism misses, replaces your record's result entirely with the referenced domain's, and is ignored whenever an all mechanism is present (Section 6.1)."
  - question: "Which SPF mechanisms count toward the 10-lookup limit?"
    answer: "Per RFC 7208 Section 4.6.4, the terms that count are include, a, mx, ptr, and exists, plus the redirect modifier. The ip4, ip6, and all mechanisms and the exp modifier never query DNS during evaluation and are exempt. A separate cap of two void lookups also applies."
  - question: "What does %{i} mean in an SPF record?"
    answer: "%{i} is a macro that expands to the connecting client's IP address — dotted-quad format for IPv4, dot-separated nibble format for IPv6 (Sections 7.2 and 7.3). It is most often paired with exists: to build per-IP hostnames, letting one lookup authorize a dynamic set of senders."
  - question: "Does the order of mechanisms in an SPF record matter?"
    answer: "Yes. Mechanisms are evaluated left to right, the first match stops processing, and that mechanism's qualifier becomes the result (Section 4.6.2). An early minus qualifier is final for any IP it matches — no later include can override it — and terms after all are never evaluated (Section 5.1)."
  - question: "How do I publish an SPF record longer than 255 characters?"
    answer: "Split it into multiple quoted strings of at most 255 bytes each, inside one TXT record. Receivers must concatenate the strings without adding spaces (Section 3.3), so the split can even fall mid-token. Keep the full DNS answer under roughly 450 octets so it fits a single UDP packet (Section 3.4)."
---
# SPF Record Syntax: Mechanisms, Qualifiers, Modifiers, and Macros

SPF record syntax follows one shape: a single DNS TXT record that starts with
`v=spf1`, followed by space-separated terms — mechanisms with optional
qualifiers, then modifiers — evaluated left to right until the first match.
Here is a complete record:

```
v=spf1 ip4:192.0.2.0/24 include:_spf.example.com -all
```

That one line authorizes a /24 network and a third party's servers, then fails
everything else. Every rule governing it lives in
[RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208), the SPF standard
published in April 2014.

This page is the full reference: every mechanism, every qualifier, both
modifiers, the complete macro table, evaluation order, DNS lookup limits, and
record placement rules — each with the RFC 7208 section that defines it. If
you want protocol fundamentals first — why SPF exists and how it fits with
DKIM and DMARC — start with our [SPF guide](/learn/spf/). If you build and
maintain records, bookmark this.

## SPF Record Syntax at a Glance

An SPF record is one string of text in the RDATA of a single TXT record, and
its grammar has exactly three kinds of parts: a version tag, mechanisms (each
with an optional qualifier), and modifiers. The version tag must be exactly
`v=spf1` — a record starting `v=spf10` is discarded, not partially matched
(§4.5).

<Figure
  src="/images/blog/spf-record-syntax/spf-record-syntax_anatomy_diagram.svg"
  alt="Labeled anatomy of an SPF record: version tag, ip4 mechanism with argument, include mechanism, minus qualifier, and terminal all mechanism"
  caption="Every SPF record is the same three kinds of parts: a version tag, mechanisms with optional qualifiers, and modifiers."
/>

The grammar in brief, from §3 and §4.6.1:

<DataTable caption="The three kinds of parts in SPF record syntax">

| Part      | Form                             | Role                                                          |
| --------- | -------------------------------- | ------------------------------------------------------------- |
| Version   | `v=spf1`, always first, exact    | Selects the record (§4.5)                                     |
| Mechanism | `[qualifier]name[:arg][/cidr]`   | Tested against the client IP; can match or not match (§4.6.2) |
| Qualifier | `+` `-` `~` `?` before mechanism | The result returned when its mechanism matches (§4.6.2)       |
| Modifier  | `name=value`, at most once each  | Extra information; never matched (§6)                         |

</DataTable>

Terms are separated by spaces. Mechanism names are case-insensitive, and terms
containing none of `=`, `:`, or `/` are mechanisms (§4.6.1). One syntax error
anywhere invalidates the whole record: check_host() — the receiver's
evaluation routine, as the RFC names it — validates SPF syntax first and returns PermError immediately if anything is
malformed, without evaluating a single term (§4.6). That is why one stray
character can fail authentication for every message a domain sends.

## SPF Mechanisms

Eight mechanisms exist, and each either matches the connecting IP or does not.
RFC 7208 §5 splits them into basic framework mechanisms (`all`, `include`) and
designated-sender mechanisms (`a`, `mx`, `ptr`, `ip4`, `ip6`, `exists`).
Before the full table: most real-world records use only `include`, `ip4`,
`ip6`, and `all` — the rest of this reference exists so you can read other
people's records, not because your own needs them.

{/* Lookup-count column semantics per §4.6.4; verified against the RFC text 2026-07-28 */}

<DataTable caption="All eight SPF mechanisms with syntax, match condition, and DNS lookup cost">

| Mechanism | Syntax               | Matches when…                                           | Counts toward 10-lookup limit? | RFC § |
| --------- | -------------------- | ------------------------------------------------------- | ------------------------------ | ----- |
| `all`     | `all`                | Always                                                  | No                             | §5.1  |
| `include` | `include:domain`     | The referenced record returns Pass                      | Yes                            | §5.2  |
| `a`       | `a[:domain][/cidr]`  | Client IP is among the domain's A/AAAA addresses        | Yes                            | §5.3  |
| `mx`      | `mx[:domain][/cidr]` | Client IP is an address of one of the domain's MX hosts | Yes (plus per-MX address caps) | §5.4  |
| `ptr`     | `ptr[:domain]`       | Reverse DNS validates into the target domain            | Yes                            | §5.5  |
| `ip4`     | `ip4:network[/cidr]` | Client IP is inside the IPv4 network                    | No                             | §5.6  |
| `ip6`     | `ip6:network[/cidr]` | Client IP is inside the IPv6 network                    | No                             | §5.6  |
| `exists`  | `exists:domain`      | The constructed domain has any A record                 | Yes                            | §5.7  |

</DataTable>

You can parse any published record term-by-term — with each mechanism's cost
and RFC cite attached — using the
[SPF syntax inspector](/tools/spf-syntax-inspector/).

### `all`

`all` always matches, which is why it belongs at the end as the explicit
default (§5.1). Everything after it is dead text: "Mechanisms listed after
`all` MUST be ignored," and any `redirect=` modifier is ignored whenever `all`
appears anywhere in the record (§5.1). A record without a trailing `all` or
`redirect=` silently defaults to Neutral (§4.7).

### `include`

`include:domain` recursively evaluates the referenced domain's SPF record and
matches only when that evaluation returns Pass (§5.2). It is not a splice. The
RFC itself concedes the name was "poorly chosen": a `-all` inside an included
record does **not** fail your outer record — a Fail, Softfail, or Neutral
inside simply means "no match here, keep going" (§5.2). Better mental model:
`if-match`. One consequence worth knowing: if the included domain publishes no
SPF record at all, `include` returns PermError (§5.2).

### `a` and `mx`

`a` matches when the client IP is one of the target domain's A or AAAA
addresses; `mx` matches when it is an address of one of the domain's MX hosts
(§5.3, §5.4). Both default to the current domain when no argument is given,
and both accept dual CIDR suffixes — `a/24` compares only the top 24 bits, and
`a:example.com/24//64` sets IPv4 and IPv6 prefixes separately (§5.3). Note the
cost asymmetry: `mx` is one term against the 10-lookup limit, but evaluating
it triggers one MX query plus an address query per MX host, capped at 10
address records before PermError (§4.6.4).

### `ip4` and `ip6`

`ip4:` and `ip6:` test whether the client IP falls inside a literal network,
using a colon — `ip4:192.0.2.0/24`, never `ip4=` (§5.6). Omitted CIDR lengths
default to `/32` and `/128`, i.e., exact-address match, and truncated
addresses like `192.0.2` are not permitted (§5.6). These are the only
designated-sender mechanisms with zero DNS cost, which makes them the cheapest
terms in any record.

### `exists`

`exists:domain` constructs a domain name, queries it for an A record, and
matches if any A record comes back — regardless of its value, and always an A
query even on IPv6 connections (§5.7). One lookup, arbitrary logic. Combined
with macros (§7), it powers dynamic authorization: publish per-IP hostnames in
a zone you control, and the record authorizes exactly those IPs without ever
listing them. This is the pattern behind Salesforce's current record, shown in
the examples below.

### `ptr` (do not use)

RFC 7208's own heading for §5.5 is literally `"ptr" (do not use)`: the
mechanism "SHOULD NOT be published" because it is slow, unreliable under DNS
errors, and burdens the `.arpa` name servers. Receivers must still support it,
but you should never publish it.

<KeyStat
  stat="41,728 domains"
  label="— 1.4% of SPF-enabled domains — still publish the deprecated ptr mechanism"
  source="DMARCguard SPF Supply Chain study — our scan of 5,499,028 Tranco domains, March 15, 2026"
  sourceHref="/research/spf-supply-chain/"
/>

Next step: paste your own record into the
[SPF syntax inspector](/tools/spf-syntax-inspector/) and confirm every
mechanism in it earns its DNS cost.

## SPF Qualifiers: +, -, ~, ?

A qualifier is a single prefix character that sets the result returned when
its mechanism matches — and when you omit it, the default is `+` (§4.6.2).
`mx` and `+mx` are the same term.

<DataTable caption="The four SPF qualifiers and their check_host() results">

| Qualifier | Result   | Meaning (§2.6)                                                         |
| --------- | -------- | ---------------------------------------------------------------------- |
| `+`       | Pass     | The client is authorized (default when omitted)                        |
| `-`       | Fail     | The client is explicitly not authorized                                |
| `~`       | Softfail | Probably not authorized; the domain isn't willing to state a hard Fail |
| `?`       | Neutral  | The domain asserts nothing about the client                            |

</DataTable>

In practice the choice comes down to the final term: `-all` versus `~all`.
Receivers treat them differently, and mail flows through forwarders complicate
the picture — we cover the trade-off in depth in
[softfail vs hardfail](/blog/spf-softfail-vs-hardfail/).

<Callout type="info" title="The two largest mailbox providers disagree on the qualifier">
  Google's setup doc tells single-provider domains to publish
  `v=spf1 include:_spf.google.com ~all` (softfail), while Microsoft recommends
  `-all` "because we also recommend DKIM and DMARC for the domain." (Google,
  [Set up SPF](https://knowledge.workspace.google.com/admin/security/set-up-spf);
  Microsoft,
  [Set up SPF for Microsoft 365](https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure)
  — both accessed 2026-07-28)
</Callout>

If you also enforce DKIM and DMARC, publish `-all`; if you are still
discovering senders, start with `~all` and tighten once
[softfail vs hardfail](/blog/spf-softfail-vs-hardfail/) settles the trade-off
for your domain.

## SPF Modifiers: redirect and exp

Modifiers are `name=value` pairs — always an equals sign, never a colon — that
provide information rather than being matched (§6). RFC 7208 defines two, each
allowed at most once; a duplicated `redirect=` or `exp=` is a PermError, while
unrecognized modifiers are ignored no matter how often they appear (§6).

### `redirect=`

`redirect=domain` hands the entire evaluation to another domain's record — but
only after every mechanism has failed to match (§6.1). The other record's
result becomes your result, with one sharpening: where a missing record would
normally give None, a `redirect=` to a domain with no SPF record gives
PermError (§6.1). Two rules people miss: `redirect=` counts as one DNS lookup
(§4.6.4), and it "MUST be ignored" whenever an `all` mechanism appears
anywhere in the record (§6.1).

How it differs from `include`:

<DataTable caption="include: versus redirect= behavior side by side">

| Behavior                    | `include:` (§5.2)                         | `redirect=` (§6.1)                         |
| --------------------------- | ----------------------------------------- | ------------------------------------------ |
| Matches on                  | Pass only; otherwise evaluation continues | Not a mechanism; applies after all misses  |
| Effect of referenced result | Only Pass propagates as a match           | Result replaces yours entirely             |
| With `all` in the record    | Unaffected                                | Ignored                                    |
| Referenced record missing   | PermError                                 | PermError                                  |
| Intended use                | Crossing administrative boundaries        | Sharing one policy across your own domains |

</DataTable>

### `exp=`

`exp=domain` names a TXT record whose macro-expanded string is returned as the
explanation when a message Fails (§6.2). It is the only term in the language
that never counts toward the lookup limit — §4.6.4 exempts `exp` explicitly,
because its lookup happens after evaluation, and only on Fail.

## SPF Macros Reference

Macros are `%{...}` sequences expanded at evaluation time from properties of
the message — and they are the least-covered corner of SPF record syntax
despite being fully specified in RFC 7208 §7. The ABNF defines exactly eleven
macro letters: `s`, `l`, `o`, `d`, `i`, `p`, `h`, `c`, `r`, `t`, `v` (§7.1).

<DataTable caption="All eleven SPF macro letters and their expansions (RFC 7208 §7.2)">

| Macro  | Expands to (§7.2)                        | Notes                                                |
| ------ | ---------------------------------------- | ---------------------------------------------------- |
| `%{s}` | The full sender, e.g. `user@example.com` |                                                      |
| `%{l}` | Local-part of the sender (`user`)        | Limits caching (§7.3)                                |
| `%{o}` | Domain of the sender                     |                                                      |
| `%{d}` | The domain being evaluated               |                                                      |
| `%{i}` | The connecting client IP                 | Dotted-quad IPv4 / dot-separated nibbles IPv6 (§7.3) |
| `%{p}` | Validated reverse-DNS name of the IP     | "do not use" (§7.2, §5.5)                            |
| `%{v}` | `in-addr` for IPv4, `ip6` for IPv6       |                                                      |
| `%{h}` | HELO/EHLO domain                         |                                                      |
| `%{c}` | Client IP, human-readable                | `exp` text only (§7.2)                               |
| `%{r}` | Domain of the host performing the check  | `exp` text only (§7.2)                               |
| `%{t}` | Current timestamp (seconds since epoch)  | `exp` text only (§7.2)                               |

</DataTable>

Three literal escapes complete the set: `%%` is a percent sign, `%_` a space,
`%-` a URL-encoded space (§7.1).

Transformers modify the expansion inside the braces (§7.3). A digit keeps only
that many right-hand parts: `%{d2}` on `email.example.com` gives
`example.com`. The letter `r` reverses the parts on dots: with client IP
192.0.2.1, `%{i}` is `192.0.2.1` and `%{ir}` is `1.2.0.192`. Custom delimiters
re-split on other characters — `%{l-}` splits the local-part on hyphens — and
parts are always rejoined with dots.

The RFC's own worked example in §7.4 ties it together. With client IP
192.0.2.3 and domain `email.example.com`:

<CodeBlock
  lang="txt"
  filename="Macro expansion — RFC 7208 §7.4 worked example"
  code={macroExpansion}
/>

Pair that with `exists:` and you get per-IP authorization in a single lookup —
the §5.7 example `v=spf1 exists:%{ir}.%{l1r+-}._spf.%{d} -all` makes decisions
"at the level of the user and client IP address." So what does `%{i}` mean in
an SPF record? It is the connecting client's IP address — dotted-quad for
IPv4, dot-separated nibble format for IPv6 — and it is the macro that makes
dynamic gateway records work (§7.2). You can expand any macro against a live
IP with the [SPF macro debugger](/tools/spf-macro-debugger/).

One caution from the RFC itself: records using `s`, `l`, `o`, or `h` macros
defeat result caching, so §7.3 advises sending domains to avoid them with
mechanism directives. Before publishing any macro, expand it against a real
client IP in the [SPF macro debugger](/tools/spf-macro-debugger/) and check
the constructed name resolves the way you expect.

## How a Receiver Evaluates Your Record

Evaluation is first-match-wins: mechanisms are considered left to right, the
first one that matches ends processing, and its qualifier becomes the result
(§4.6.2). Order is not stylistic — it is the semantics.

<Figure
  src="/images/blog/spf-record-syntax/spf-record-syntax_evaluation-order_flowchart.svg"
  alt="First-match-wins evaluation of the record v=spf1 ip4:192.0.2.0/24 -a mx ~all for client IP 198.51.100.7: ip4 and -a do not match, mx matches and evaluation stops with result Pass"
  caption="First-match-wins evaluation of an SPF record, step by step. The ~all is never reached."
/>

Trace client IP 198.51.100.7 through this record:

<CodeBlock
  lang="txt"
  filename="First-match-wins evaluation trace"
  code={evaluationTrace}
/>

The `~all` is never reached. Now invert the case: if the IP **had** matched
step 2, the result would be Fail — and no later mechanism could rescue it. A
`-` qualifier early in the record is final for any IP it matches, no matter
what an `include` further right would have said. This also answers the
ordering question directly: yes, mechanism order matters, because evaluation
stops at the first match (§4.6.2).

If nothing matches and there is no `redirect=`, the default result is Neutral,
"just as if `?all` were specified as the last directive" (§4.7). The RFC
recommends terminating explicitly with `all` or `redirect=` anyway — implicit
defaults make debugging harder.

## DNS Lookup Limits: What Counts and What Doesn't

The rule, verbatim from §4.6.4: "SPF implementations MUST limit the total
number of those terms to 10 during SPF evaluation … If this limit is exceeded,
the implementation MUST return 'permerror'." Which terms count is fully
enumerated:

<DataTable caption="Which SPF terms count toward the 10-DNS-lookup limit (RFC 7208 §4.6.4)">

| Term                | Counts toward the 10? | Extra caps (§4.6.4)                                            |
| ------------------- | --------------------- | -------------------------------------------------------------- |
| `include`           | Yes                   | Nested lookups inside it also count                            |
| `a`                 | Yes                   | —                                                              |
| `mx`                | Yes                   | ≤10 address (A/AAAA) queries per MX evaluation, else PermError |
| `ptr`               | Yes                   | ≤10 address queries per PTR evaluation; excess records ignored |
| `exists`            | Yes                   | —                                                              |
| `redirect`          | Yes                   | —                                                              |
| `ip4`, `ip6`, `all` | No                    | Never query DNS                                                |
| `exp`               | No                    | Looked up later, only on Fail — explicitly exempt              |

</DataTable>

There is a second, separate cap most references skip: **void lookups**.
Queries that return no answer (RCODE 0 with zero answers, or NXDOMAIN) SHOULD
be limited to two, with two as the RECOMMENDED default — and "exceeding the
limit produces a 'permerror' result" (§4.6.4). A record full of stale includes
can PermError on void lookups long before it reaches ten total.

<KeyStat
  stat="148,655 of 3,077,219"
  label="SPF-enabled domains — 4.8% — exceed the 10-lookup limit and risk PermError on every message they send"
  source="DMARCguard SPF Supply Chain study — our scan of 5,499,028 Tranco domains, March 15, 2026"
  sourceHref="/research/spf-supply-chain/"
/>

If your own record is over the limit, the step-by-step reduction path lives in
[fix SPF too many DNS lookups](/blog/spf-too-many-dns-lookups/).

{/* CTA slot (DNS Lookup Limits section) if needed: SPF flattener → /tools/spf-flattener/ */}

## Record Placement Rules: One Record, TXT Only, 255-Byte Strings

### Exactly one SPF record per name

A domain name "MUST NOT have multiple records that would cause an
authorization check to select more than one record" (§3.2) — and the
consequence is explicit, not vague: when record selection finds more than one
`v=spf1` record, check_host() "produces the 'permerror' result" (§4.5). Two
SPF records don't merge, don't race, and don't half-work. They fail
authentication outright. If you have two, merge the mechanisms into one
record.

### TXT type only

"SPF records MUST be published as a DNS TXT (type 16) Resource Record (RR)
only" (§3.1). The dedicated SPF record type (type 99) from SPF's experimental
phase was dropped by the SPFbis working group, which concluded the
dual-record-type model "was fundamentally flawed" (§3.1).

<KeyStat
  stat="107,646 domains"
  label="still publish the deprecated type-99 SPF record"
  source='Czybik, Horlboge & Rieck, "Lazy Gatekeepers: A Large-Scale Study on SPF Configuration in the Wild," ACM IMC 2023'
  sourceHref="https://arxiv.org/abs/2502.08240"
/>

### The 255-byte limit is per string, not per record

This is the rule the SERP most consistently gets wrong. The 255-octet maximum
applies to a single _character-string_ inside a TXT record — not to the
record. A TXT record can hold multiple quoted strings, and §3.3 is
unambiguous: "If a published record contains multiple character-strings, then
the record MUST be treated as if those strings are concatenated together
without adding spaces." So this:

<CodeBlock
  lang="dns"
  filename="One TXT record split across two quoted strings (§3.3)"
  code={multistringRecord}
/>

evaluates as `v=spf1 ip4:192.0.2.0/24 include:_spf.example.com -all` — note
the split lands mid-token and concatenation heals it, because no space is
inserted. That is the correct way to publish a record longer than 255
characters. The practical ceiling is elsewhere: §3.4 recommends keeping the
whole DNS answer small enough to fit a 512-octet UDP packet (roughly 450
octets of name-plus-records), since oversized answers can be silently dropped
by middleboxes that mishandle DNS over TCP.

## SPF Record Examples

Four annotated records, from minimal to macro-based. Lookup counts were
computed by walking each chain with `dig +short TXT` on July 28, 2026 —
provider records change (Google flattened `_spf.google.com` from 4 lookups to
1 in December 2025), so always re-verify rather than trust a blog's cached
number.

**1. Single provider (Google's own published string —
[Set up SPF](https://knowledge.workspace.google.com/admin/security/set-up-spf)):**

{/* dig txt _spf.google.com +short → flat ip4/ip6 list, no nested includes. Verified 2026-07-28: 1 lookup total */}

```
v=spf1 include:_spf.google.com ~all
```

Reads as: authorize Google's servers; softfail everything else. Cost: 1
lookup — `_spf.google.com` now resolves to a flat `ip4`/`ip6` list.

**2. Provider plus your own mail server:**

{/* Verified 2026-07-28: mx=1 + include:_spf.google.com=1 → 2 lookups */}

```
v=spf1 ip4:192.0.2.10 mx include:_spf.google.com -all
```

Reads as: authorize one fixed IP, your MX hosts, and Google; fail everything
else. Cost: 2 lookups (`mx`, `include` — the `ip4` is free).

**3. Multi-SaaS — Google plus Microsoft 365 (a combination Google's doc
publishes verbatim):**

{/* dig txt spf.protection.outlook.com +short → flat ip4/ip6 list, no nested includes. Verified 2026-07-28: 1+1 = 2 lookups */}

```
v=spf1 include:_spf.google.com include:spf.protection.outlook.com ~all
```

Reads as: authorize both providers; softfail the rest. Cost: 2 lookups — both
includes currently resolve to flat address lists.

**4. Macro-based dynamic authorization (Salesforce's live record for
`_spf.salesforce.com`):**

{/* dig txt _spf.salesforce.com +short → "v=spf1 exists:%{i}._spf.mta.salesforce.com -all". Verified 2026-07-28; this record itself costs 1 exists lookup — a domain that include:s it pays 1 + 1 */}

```
v=spf1 exists:%{i}._spf.mta.salesforce.com -all
```

Reads as: for each connection, ask whether
`<client-ip>._spf.mta.salesforce.com` has an A record; pass if yes, fail
otherwise. One `exists` lookup authorizes an entire fleet without listing a
single address — the §5.7 pattern running in production.

Want to assemble your own? The [SPF generator](/tools/spf-generator/) builds a
record from your providers, and the [SPF checker](/tools/spf-checker/)
validates the result against every rule on this page.

## Common SPF Syntax Mistakes

Genuine syntax errors are rarer than vendor marketing implies — and the honest
numbers are worth stating. The largest peer-reviewed study of SPF
configuration found that only 2.9% of 7,251,736 SPF-enabled domains had any
error at all, and true syntax mistakes were 18.15% of those errors — roughly
0.5% of all SPF-enabled domains (Czybik et al., ACM IMC 2023,
[arxiv.org/abs/2502.08240](https://arxiv.org/abs/2502.08240)). Keep the
categories separate: syntax errors, lookup-limit PermErrors, and permissive
policy choices are three different problems.

The mistakes that do occur are consistent. Each row below is an observed error
class with its share of syntax errors where the IMC 2023 study measured it:

<DataTable caption="Observed SPF syntax mistakes: wrong form, right form, and the governing rule">

| Mistake                                                      | Wrong                                    | Right                                   | Rule                                                  |
| ------------------------------------------------------------ | ---------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| `ipv4` instead of `ip4` (11.0% of syntax errors, IMC 2023)   | `ipv4:192.0.2.0/24`                      | `ip4:192.0.2.0/24`                      | §5.6 defines `ip4`/`ip6`                              |
| Space after the colon (16.6%, IMC 2023)                      | `include: example.com`                   | `include:example.com`                   | §4.6.1 ABNF permits none                              |
| `=` where `:` belongs (Microsoft-documented common error)    | `ip4=192.0.2.0` / `include=example.com`  | `ip4:192.0.2.0` / `include:example.com` | Mechanisms take `:`; only modifiers take `=` (§4.6.1) |
| More than one `v=spf1` record (15.3%, IMC 2023)              | Two TXT records                          | One merged record                       | §3.2 / §4.5 → PermError                               |
| Terms after `all`                                            | `v=spf1 -all include:...`                | `v=spf1 include:... -all`               | §5.1 — silently ignored                               |
| SPF concatenated with a verification string (7.0%, IMC 2023) | `v=spf1 ...google-site-verification=...` | Separate TXT records                    | §4.5 selection breaks                                 |

</DataTable>

The `=`-for-`:` confusion is documented by Microsoft among the most common
errors its support teams see, alongside stray spaces and trailing periods
([Microsoft SPF doc](https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure),
accessed 2026-07-28).

If your DMARC reports already show PermError and you need the diagnosis path
rather than the reference, that flow lives in
[fix SPF PermError](/blog/spf-permerror-fix/).

## FAQ

### Can a domain have two SPF records?

No. RFC 7208 permits exactly one SPF record per domain name (§3.2), and when
record selection finds more than one `v=spf1` TXT record, the result is
PermError (§4.5) — authentication fails for every message. Merge the
mechanisms from both records into a single `v=spf1` record instead.

### What is the difference between include and redirect in SPF?

`include:` matches only when the referenced record returns Pass, and
evaluation continues if it doesn't (§5.2). `redirect=` applies only after
every mechanism misses, replaces your record's result entirely with the
referenced domain's, and is ignored whenever an `all` mechanism is present
(§6.1).

### Which SPF mechanisms count toward the 10-lookup limit?

Per RFC 7208 §4.6.4, the terms that count are `include`, `a`, `mx`, `ptr`, and
`exists`, plus the `redirect` modifier. The `ip4`, `ip6`, and `all` mechanisms
and the `exp` modifier never query DNS during evaluation and are exempt. A
separate cap of two void lookups also applies.

### What does %\{i\} mean in an SPF record?

`%{i}` is a macro that expands to the connecting client's IP address —
dotted-quad format for IPv4, dot-separated nibble format for IPv6 (§7.2,
§7.3). It is most often paired with `exists:` to build per-IP hostnames,
letting one lookup authorize a dynamic set of senders.

### Does the order of mechanisms in an SPF record matter?

Yes. Mechanisms are evaluated left to right, the first match stops processing,
and that mechanism's qualifier becomes the result (§4.6.2). An early `-`
qualifier is final for any IP it matches — no later `include` can override
it — and terms after `all` are never evaluated (§5.1).

### How do I publish an SPF record longer than 255 characters?

Split it into multiple quoted strings of at most 255 bytes each, inside one
TXT record. Receivers must concatenate the strings without adding spaces
(§3.3), so the split can even fall mid-token. Keep the full DNS answer under
roughly 450 octets so it fits a single UDP packet (§3.4).

## The reference, in four lines

SPF record syntax rewards precision, and the whole standard reduces to a short
list worth keeping:

- **One record, TXT only.** Exactly one `v=spf1` TXT record per name; a second
  one is a PermError, not a merge (§3.1, §3.2).
- **First match wins.** Left to right, stop at the first matching mechanism,
  return its qualifier (§4.6.2). Order is policy.
- **Know your lookup budget.** Ten DNS-querying terms, two void lookups —
  exceed either and every message fails (§4.6.4).
- **Macros are standard, not folklore.** Eleven letters in §7 power the
  dynamic records Salesforce runs in production today.

<CTA
  title="Validate your record against every rule on this page"
  description="Paste your SPF record into the free checker — it walks every include, counts every lookup, and flags every syntax rule from RFC 7208. No signup required."
  label="Open the SPF checker"
  href="/tools/spf-checker/"
/>

And when you're ready to see how your record performs against real mail flows,
DMARCguard's aggregate reports show which sources pass, which fail, and
exactly what to change.

{/* TODO: link google-workspace-spf-record after 2026-08-17 (batch forward pair, reciprocal) */}