SPF flattening: when it helps and when it breaks mail
Flattening replaces includes with IP ranges to dodge the lookup limit. Here is how it works, why it rots, and safer alternatives like subdomains.

On this page(8 sections)
- What flattening does
- Why it rots
- Record size becomes a problem
- When flattening is reasonable
- Cleaner alternatives to try first
- Remove dead weight
- Use subdomains for the envelope sender
- Rely on aligned DKIM where appropriate
- Drop mx and a if they are not senders
- A minimal automation sketch
- Signs your flattened record has gone stale
- Bottom line
SPF flattening is the duct tape of email authentication: it gets you under the ten-lookup limit this afternoon and fails quietly six months from now. Used carefully, it is a legitimate tool.
Used casually, it is a time bomb.
What flattening does
An SPF record with several include: mechanisms makes the receiver perform a DNS query for each include, plus queries for anything nested inside them. Flattening resolves all of that ahead of time and replaces the includes with the literal IP ranges they contain.
Before:
v=spf1 include:_spf.mailhost.example include:spf.crm.example include:mail.helpdesk.example ~all
After:
v=spf1 ip4:192.0.2.0/25 ip4:192.0.2.128/26 ip4:198.51.100.0/24 ip6:2001:db8:10::/48 ip4:203.0.113.32/27 ~all
Because ip4: and ip6: do not require DNS lookups, the flattened record costs zero lookups no matter how many ranges it lists. Problem solved, on paper.
Why it rots
The includes you removed were not decoration. They were a delegation: "whatever IPs this vendor says it uses today are authorized." When you flatten, you replace a live delegation with a snapshot.
Vendors change their sending infrastructure for many reasons: new data centers, migrations between cloud providers, new IP pools for warm-up, retiring old blocks. Large providers publish their ranges through SPF precisely so customers do not have to track these changes. When your flattened record misses a new range:
- Mail from the new IPs fails SPF.
- If the vendor also signs with an aligned DKIM key, DMARC may still pass, and nobody notices.
- If it does not, or if a receiver weighs SPF separately, mail starts landing in spam or bouncing.
- The failure is intermittent, because only some of the vendor's traffic moved.
That last point is what makes stale flattening so painful to debug. Most messages work. A fraction fail. The record looks valid in every checker.
There is also a quieter risk in the other direction. When a vendor retires a range, your snapshot still authorizes it. If that range is later reassigned to someone else, you have authorized a stranger to send as your domain.
Record size becomes a problem
Flattening also tends to produce long records. Some vendors publish dozens of ranges. Each TXT string is limited to 255 characters, so long records are split into multiple quoted strings, which is legal and concatenated by receivers. But very large DNS responses can exceed the classic 512-byte UDP limit and require EDNS or a retry over TCP. Most modern resolvers handle this, but not every network path does, and a TXT response that fails to arrive at all yields a temperror rather than a pass.
You can split a large flattened set across multiple records chained with include: to your own subdomains (_spf1.example.com, _spf2.example.com). That reintroduces some lookups, but you control them.
When flattening is reasonable
Flattening is defensible under specific conditions:
- It is automated. A job re-resolves the original includes on a schedule (hourly or daily), regenerates the record, and publishes it through your DNS provider's API.
- It is monitored. The job alerts when a vendor's resolved ranges change, when publishing fails, or when the regenerated record would exceed size limits.
- The source of truth is preserved. You keep the original include list in version control, so anyone can see what the flattened record is supposed to represent.
- You have exhausted cleaner options. Removing unused senders and moving senders to subdomains usually solve the problem without any of the risk.
Hosted flattening services exist that do the automation for you, typically by having you include: a record they maintain. That works, but you are now trusting a third party with your SPF authorization, and you are still spending one lookup on their include.
Cleaner alternatives to try first
Remove dead weight
Audit which includes still correspond to services that send for you. DMARC aggregate reports show which source IPs actually send with your domain. Anything with no traffic for a few months is a candidate for removal.
Use subdomains for the envelope sender
SPF checks the envelope sender domain, not the From header. If your marketing platform uses bounce.news.example.com as its Return-Path, the SPF check happens against that subdomain's record, with its own lookup budget. Relaxed DMARC alignment still lets a From address at example.com pass on that SPF result.
This is the single most effective fix for lookup pressure, and most providers support custom bounce domains.
Rely on aligned DKIM where appropriate
DMARC needs only one aligned pass. A vendor signing with a DKIM key under your domain (often via CNAMEs you publish at a selector) can pass DMARC without appearing in your root SPF record. This is not a reason to drop SPF everywhere, but it is a reason not to cram every vendor into one record.
Drop mx and a if they are not senders
These cost lookups and often authorize hosts that never send outbound mail.
A minimal automation sketch
If you do flatten, the job looks roughly like this:
import dns.resolver, ipaddress
SOURCES = ["_spf.mailhost.example", "spf.crm.example"]
def expand(name, depth=0, seen=None):
seen = seen or set()
if depth > 10 or name in seen:
raise RuntimeError(f"loop or depth exceeded at {name}")
seen.add(name)
nets = set()
for rr in dns.resolver.resolve(name, "TXT"):
txt = b"".join(rr.strings).decode()
if not txt.startswith("v=spf1"):
continue
for term in txt.split()[1:]:
if term.startswith(("ip4:", "ip6:")):
nets.add(str(ipaddress.ip_network(term[4:], strict=False)))
elif term.startswith("include:"):
nets |= expand(term[8:], depth + 1, seen)
elif term.split(":")[0] in ("a", "mx", "exists", "ptr") or term.startswith("redirect="):
raise RuntimeError(f"unsupported term {term} in {name}; review manually")
return nets
The important part is not the code. It is that the job refuses to guess when it meets a mechanism it cannot safely flatten, and that a human gets an alert.
Signs your flattened record has gone stale
If you inherit a flattened record, look for these symptoms: DMARC reports showing a vendor's mail failing SPF from IPs you do not list, intermittent spam placement for one tool while others are fine, and ranges in your record that no longer appear in the vendor's published SPF. Comparing your record against a fresh resolution of the vendor's include usually confirms it within minutes.
Bottom line
Flattening trades a hard, visible failure (permerror from too many lookups) for a soft, invisible one (stale IP ranges). Try removing unused senders and moving providers onto their own bounce subdomains first. If you still need flattening, automate the refresh, alert on changes, keep the original include list in version control, and treat the flattened record as generated output, never as something a person edits by hand.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


