SPF Macro Syntax, Explained Properly

How SPF's macro expansion language lets a record build a dynamic DNS query at evaluation time instead of a fixed one, letter by letter, with real examples.

📅 Published August 2026· ⏳ 15 min read· ✍️ ToolsNovaHub Editorial Team
🛠️ Related tool: Open SPF Lookup →

Why SPF Needs a Macro Language at All

Most SPF mechanisms are static: an ip4 range is fixed, an include points at a fixed domain, an a or mx checks a fixed hostname. That covers the overwhelming majority of real-world SPF needs, but there's a category of use case those static mechanisms can't reach — situations where the DNS query itself needs to change based on details of the specific message being checked, like the sending IP or the exact sender address. RFC 7208 solves this with a small macro-expansion language: a set of percent-letter placeholders that get substituted with real, check-specific values before the resulting string is used, most commonly inside an exists: mechanism or the exp= explanation modifier.

The core idea is straightforward even though the syntax looks unfamiliar at first glance: write a domain name template with macro placeholders in it, and let SPF fill those placeholders in with real data at evaluation time, producing a different actual DNS query for every different sender or IP being checked.

ToolsNovaHub Pro Tip
If you've never needed a macro, that's normal, not a gap in your knowledge. Macros exist for a specific class of dynamic per-IP or per-sender checks that the vast majority of everyday SPF records simply don't require.
⚠️
Common Beginner Mistake
Assuming %{d} always means "my domain." It means whatever domain is currently being evaluated at that point in the process — which changes if evaluation has moved into an included record from a different domain.

The Core Macro Letters

MacroExpands ToExample Value
%{i}Sending IP address203.0.113.45
%{s}Full envelope sender (MAIL FROM)user@example.com
%{l}Local part of the sender onlyuser
%{o}Domain part of the sender, fixed at originalexample.com
%{d}Domain currently under evaluation (can change through includes)example.com or an included domain
%{h}HELO/EHLO domain from the SMTP sessionmail.example.com
%{ir}Sending IP, octets reversed45.113.0.203

A Worked Example: Per-IP Existence Checks

Consider a mechanism written as exists:%{ir}.spf-tracker.example.com. For a message arriving from IP 203.0.113.45, the reversed-octet macro %{ir} expands to 45.113.0.203, producing an actual DNS query for 45.113.0.203.spf-tracker.example.com. Whether that specific hostname exists in DNS (returns any A record at all, regardless of what it points to) determines whether the exists mechanism matches. Because the query itself changes based on the sending IP, this pattern lets a domain owner build per-IP allow lists, rate-limiting logic, or reputation checks entirely inside DNS, evaluated fresh on every check, without needing a database lookup on the receiving side.

📡
Per-IP Reputation Checks
The most common real-world macro use: building a distinct DNS name per sending IP to check against a maintained allow/deny list.
💬
Dynamic Explanation Text
exp= modifiers use macros to build a rejection message that actually names the failing IP or sender, rather than a generic error.
📊
Sender-Specific Tracking
Some large mail operations use %{s} or %{l} macros to build per-account or per-campaign existence checks for internal analytics.

Transformation Modifiers

A macro letter can be followed by optional digits and an "r" flag before the closing brace, which control how the underlying value gets transformed before insertion. A digit limits the macro to the rightmost N parts of a dotted value (useful for truncating a long domain to just its last few labels), and the "r" flag reverses the order of the parts entirely, which is exactly what's needed to build the reverse-DNS-style query pattern shown in the example above. These transformations exist specifically because DNS domain-name construction has its own ordering conventions that don't always match the natural left-to-right reading order of an IP address or domain string.

Macros in the Explanation Modifier

The exp= modifier, evaluated only when a record explicitly results in a fail, points at a TXT record whose content becomes a human-readable explanation string that can itself contain macros. This lets a domain publish a rejection explanation that dynamically names the actual IP or sender that failed, rather than a single static message for every failure — useful for domains that want to give more actionable feedback to whatever system or person is reading bounce messages.

When Not to Reach for Macros

Macros solve a narrow, specific problem: building a dynamic query based on per-check data. If your actual need is simply "authorize this list of vendors" or "authorize these IP ranges," macros add complexity with zero benefit — static mechanisms handle that case directly and are far easier for the next person maintaining the record to read and verify. Macro-based exists: mechanisms are worth reaching for specifically when the authorization decision genuinely needs to vary per sending IP or sender in a way a fixed list can't express.

Walking Through the %{d} Macro's Changing Value in Detail

The %{d} macro deserves a closer look because it's the one most likely to produce a surprising result for anyone assuming macros are simple, static substitutions. At the start of SPF evaluation for a message claiming to be from user@example.com, %{d} expands to example.com. If evaluation then reaches an include: mechanism pointing at a vendor's domain, say vendor.net, and that vendor's own record itself contains a macro using %{d}, the value at that point in evaluation is vendor.net, not example.com — because %{d} always reflects whatever domain's record is currently being evaluated, and evaluation has moved into vendor.net's own record by that point. This is different from %{o}, which stays fixed at the original envelope sender's domain (example.com) throughout the entire evaluation, regardless of how deep into nested includes the process goes. Confusing these two macros is a genuine, documented source of misconfigured exists: mechanisms, particularly in records built by copying a pattern from one vendor's documentation without checking whether that vendor's use of %{d} versus %{o} actually matches the intended behavior in a different, deeper nesting context.

A Second Worked Example: Sender-Based Existence Checks

Beyond per-IP checks, some domains use macros to build per-sender lookups instead. A mechanism like exists:%{l1r}.%{d}.senderpolicy.example.com takes the local part of the envelope sender, truncates it to just its rightmost single label using the "1" transformation digit, reverses it with the "r" flag, and combines it with the current evaluation domain to build a per-account lookup hostname. For a message sent as billing@example.com, this might expand to something like gnillib.example.com.senderpolicy.example.com (illustrating the label-reversal transformation on the local part). Whether that specific hostname resolves to anything determines the mechanism's match result, letting a domain maintain a DNS-based allow list keyed to individual sending accounts rather than IP addresses — useful in large organizations where dozens of internal systems each send under a shared domain but need to be individually revocable without touching the main SPF record.

Testing Macro Behavior Before Publishing

Because a malformed macro produces a permerror rather than a silently wrong result, it's worth validating the exact expanded query a macro-based mechanism would produce for a few realistic test cases before publishing it. Manually work through what %{i}, %{s}, %{d}, or whichever macros you're using would expand to for two or three real sending IPs or sender addresses you expect to see, and confirm the resulting hostname is both syntactically valid and actually resolves the way you intend at the DNS records it's meant to check against. This kind of manual trace is tedious but considerably cheaper than discovering a macro-based mechanism has been silently causing a permerror for every message since it was published, simply because a transformation digit was off by one.

The Full List of Macro Letters, Beyond the Common Few

MacroMeaning
%{c}SMTP client IP, in a readable format (used mainly in exp= explanation text)
%{r}The domain performing the SPF check itself (the receiving system's own domain)
%{t}Current UNIX timestamp, primarily useful for exp= explanation text with time context
%{p}The validated domain name of the sending host from reverse DNS, discouraged similarly to ptr for reliability reasons
%%A literal percent sign, for escaping when % needs to appear outside a macro context
%_Expands to a space character, useful for readability in exp= explanation text
%-Expands to a URL-encoded space (%20), useful when the explanation text needs to be URL-safe

Most of these beyond the core %{i}, %{s}, %{d}, %{l}, and %{o} set are specifically oriented toward building explanation text for the exp= modifier rather than toward exists: mechanisms, since a human-readable rejection message benefits from things like a formatted timestamp or literal spacing that a DNS hostname lookup has no use for.

A Detailed Trace of the Reversed-IP Pattern

The %{ir} pattern used for per-IP reputation checks deserves a fuller worked trace since it's the single most common real-world macro use. For a sending IP of 198.51.100.77, %{i} alone would expand to the literal string 198.51.100.77. Adding the "r" reversal flag changes the ordering of the dot-separated octets, producing 77.100.51.198. This reversed form is deliberately chosen to mirror how reverse DNS zones are structured, where more specific address information appears leftmost in the hostname — building a query like exists:77.100.51.198.reputation.example.com means that reputation.example.com's zone administrator can organize their DNS records in nested blocks that roughly correspond to network structure, similar in spirit to how the standard in-addr.arpa reverse DNS hierarchy is organized, even though this exists: pattern has nothing to do with actual reverse DNS resolution itself.

Why Macros Complicate Record Auditing

Every other SPF mechanism produces the same DNS query every single time it's evaluated, which makes auditing straightforward: you can resolve it once and know exactly what it does for every sender, forever (until the underlying record changes). A macro-based exists: mechanism breaks that assumption entirely — its actual query depends on per-message data, so there is no single "the query this mechanism makes" to resolve and check once. Properly auditing a macro-based mechanism means either tracing through the macro logic by hand for several representative cases, or actually generating test messages from a handful of real IPs/senders and confirming the resulting queries behave as intended. This is meaningfully more work than auditing a static mechanism, and it's part of why macros should be reserved for cases that genuinely need per-check dynamism rather than reached for as a general-purpose tool.

Macro Syntax Grammar in Full

The formal structure of a macro expression is a percent sign, an opening curly brace, a single-letter macro identifier, optional decimal digits specifying how many rightmost (or leftmost, depending on the flag) parts of a dotted value to retain, an optional "r" character to reverse ordering, optional delimiter characters that override the default dot separator, and a closing curly brace. Getting any piece of this wrong — an unrecognized letter, digits in the wrong position, an unbalanced brace — is treated as a syntax error under RFC 7208's formal grammar and results in a permerror, exactly as covered in this series' broader discussion of permerror causes. This level of strictness is deliberate: macros build dynamic DNS queries, and a lenient parser that tried to guess at malformed macro intent would introduce exactly the kind of ambiguity SPF's specification otherwise goes out of its way to avoid.

Custom Delimiters in Macro Expressions

Beyond the digit-and-reversal transformations covered earlier, a macro can also specify custom delimiter characters to use instead of the default dot when splitting a value into parts for truncation or reversal purposes. This is a narrower, more specialized feature than the core letter-and-reversal syntax, primarily useful when the macro is operating on a value like an email address's local part that might contain characters like plus signs or dots in positions where dot-based splitting wouldn't produce the intended grouping. In practice, the overwhelming majority of real-world macro usage sticks to the default dot delimiter and the basic digit/reversal transformations, since most values being manipulated (IP addresses, simple domain names) are naturally dot-delimited already.

Macros and Privacy Considerations

Building a per-sender or per-IP existence check with macros means the receiving side of that exists: mechanism — whatever domain you're querying against — necessarily observes every individual sender address or IP that triggers a check against it, since each one produces a distinct, identifiable DNS query. For an internal reputation or rate-limiting system you control end to end, this is simply how the mechanism is meant to work. But if a macro-based mechanism ever points at infrastructure outside your own control, it's worth being aware that you're effectively disclosing per-message sender or IP information to that third party on every single evaluation, which is a consideration worth weighing deliberately rather than something that follows automatically just because a macro pattern happens to solve the immediate technical problem.

Macros Outside SPF: A Brief Comparison Point

For anyone familiar with templating or string-interpolation syntax from programming contexts, SPF's macro language occupies a similar conceptual space but with a much narrower, DNS-safe purpose: producing valid, safely-formed hostnames rather than arbitrary strings. This constraint — every macro expansion must ultimately produce something that's a syntactically valid DNS query — is why the transformation options are limited to things like truncation and reversal rather than more general string manipulation. It's a purpose-built, narrow-scope language, not a general templating system that happens to be used inside DNS records.

Macros and Case Sensitivity in Their Expanded Output

Worth noting explicitly: macro expansions that produce hostnames, like the reversed-IP pattern, are subject to the same DNS case-insensitivity covered elsewhere in this series — a macro-generated query is evaluated identically regardless of whatever case the surrounding static portions of the exists: mechanism's domain happen to be written in. This is rarely a practical concern in itself, but it's worth confirming when designing a macro-based DNS structure that the zone hosting the target records doesn't rely on case-sensitive matching anywhere in its own configuration, since standard DNS resolution won't preserve or respect case distinctions during the actual lookup.

Related Reading in This Series

For how the exists mechanism macros commonly appear in fits into overall lookup counting, see SPF 10 Lookup Limit. For the broader set of causes behind a permerror, including malformed macro syntax, read SPF PermError Fix. For general guidance on keeping a record maintainable, see SPF Record Optimization. To check how your own record currently evaluates, open SPF Lookup.

Reviewed by: ToolsNovaHub Editorial Team📅 Last updated: August 2026📜 Sourced from: RFC 7208

ToolsNovaHub tools are built and independently maintained with a focus on accurate, no-signup network and security utilities. Spotted an error? Let us know.

📋 Related Tools & Guides Comparison

ResourceTypeLink
SPF LookupToolOpen Tool →
SPF Include MechanismGuideRead Guide →
SPF PermError FixGuideRead Guide →
SPF Record OptimizationGuideRead Guide →
DNS LookupToolOpen Tool →
Try it yourself — 100% free
🚀 Open SPF Lookup

🔗 More Guides

FAQ

A macro is a placeholder inside an SPF record, written as a percent sign followed by a letter in curly braces, that gets substituted with real data about the current SPF check — like the sending IP or the domain being checked — before that part of the record is evaluated.
Primarily inside exists: mechanisms and inside the explanation modifier (exp=), where the whole point is constructing a domain name or message dynamically based on the specific check being performed.
The sending IP address in its expanded form — for IPv4, the dotted decimal address; for IPv6, the address expanded into a specific dot-separated nibble format rather than standard colon notation.
The full envelope sender address (MAIL FROM) exactly as presented in the SMTP transaction, local part and domain together.
The current domain being checked, which starts as the domain in the envelope sender but can change value partway through evaluation if the check has moved into an included or redirected record.
Just the local part of the envelope sender address — the portion before the @ symbol, without the domain.
The domain part of the envelope sender specifically, as originally presented, which stays fixed even as %{d} might change through includes, unlike %{d}.
Yes. A macro can include optional digits and letters after the base letter that control transformations — for example, %{ir} reverses the octets of the sending IP, which is exactly the format needed to build a reverse-DNS-style lookup.
Building a per-sender-IP existence check against an external reputation or rate-limiting service, using something like exists:%{ir}.spf-tracker.example.com, so each distinct sending IP triggers a distinct, individually trackable lookup.
No, they're relatively rare outside of specific technical use cases like large-scale rate limiting, per-IP tracking, or certain anti-abuse mechanisms; most everyday SPF records never use a macro at all.
No additional cost beyond the mechanism itself — an exists: mechanism using a macro still counts as exactly one lookup, the macro just determines what specific query gets sent.
Yes, malformed macro syntax (an unrecognized letter, invalid transformation digits, or unbalanced braces) is treated as a syntax error in the record and results in a permerror during evaluation.
Yes, directly — exp= (the explanation modifier) is specifically designed to use macros to build a human-readable rejection message that can reference the actual IP, sender, or domain involved in a failed check.
Compliant implementations following RFC 7208 support the full defined macro set, but since macros are used in a comparatively small fraction of real-world records, some older or simplified SPF libraries have historically had incomplete macro support.
Yes — %{h} represents the HELO/EHLO domain presented during the SMTP session, which is a separate piece of data from %{d}, the domain currently being checked under SPF evaluation.