Diagnostic Core Records
CNAME coexists with another record type at same name
Someone added a CNAME to a hostname that already had an A, MX, or TXT record, and the DNS host let both entries save. Now the zone quietly serves broken or unpredictable answers instead of rejecting the second record outright. Here is why the rule exists, how to find every broken name in your zone, and a script that cleans it up through the Cloudflare API.
A CNAME record means "this name is only an alias, look elsewhere for the real answer." DNS protocol rules do not allow any other record type to sit at that same name, so if a CNAME and a TXT, A, or MX record both exist at the identical hostname, the zone is broken. Pick the record that must stay, delete or relocate the rest, and confirm with dig that only one record type answers at that name.
The problem in plain words
A CNAME is not a normal record. It does not hold an answer of its own. It just points a resolver to a different name and says "go look there instead." Because of that, DNS rules say a CNAME must be the only record at its name. If any other record type, an A, an MX, a TXT, anything, also exists at that exact hostname, a resolver has no reliable way to know which one is correct.
This is not a style preference, it is a protocol rule written in RFC 1035 and reinforced in RFC 1912. Most DNS hosts try to stop you from creating this state, but zone files edited by hand, records created at different times by different tools, or bulk imports can still let both entries slip in. The zone does not throw an error. It just starts giving out answers that do not behave the way anyone expects.
Why it happens
- Someone adds a CNAME for a marketing tool or CDN (like a landing page builder or a CDN vendor) to a hostname that already has an A record pointing at your own server.
- A TXT record for SPF, DKIM, or DMARC, or a domain ownership verification token, gets added to a name that a CDN or email marketing tool already pointed elsewhere with a CNAME.
- The zone file or DNS host's UI let both entries save because they were created at different times, by different people, or by different tools, and nothing checked for the conflict.
- A bulk import or migration between DNS hosts copies every record as-is, including a pair that was already in conflict, so the problem moves with the zone instead of getting caught.
A classic real-world trigger is adding a TXT record for _dmarc or SPF to a name that a CDN or email-marketing tool already pointed at with a CNAME. The TXT record silently never gets served, because the CNAME wins. The fix is never "add both and hope." One of the two records does not belong at that name.
The fix, as a flow
Pick the record that must remain the alias, then remove or relocate everything else at that exact name. If the CNAME is required, for example www CNAME www.example.com.cdn.example.net, delete the conflicting A, MX, or TXT records at www, and if you needed that TXT for SPF, DMARC, or verification, move it to a name that is not aliased, or add the value at the target of the CNAME instead.
If the other records are the ones you actually need, for example MX at sub.example.com for mail, delete the CNAME at sub and replace it with a direct A or AAAA record pointing at the correct IP, so MX and TXT can coexist normally. Never point a CNAME at the zone apex itself, since SOA and NS records are mandatory there. Use an ALIAS or CNAME-flattening feature instead, such as Cloudflare's automatic CNAME flattening or a Route 53 Alias record.
How to fix it
Confirm the exact conflict at the name
Pull the CNAME answer for the suspect name, then check every other record type at that identical name. A bad result looks like a CNAME answer for the name returned by one query while another type query for the exact same name also returns an answer, rather than an empty response or NXDOMAIN.
dig +noall +answer CNAME app.example.com
dig +noall +answer A app.example.com
dig +noall +answer MX app.example.com
dig +noall +answer TXT app.example.com
# one shot against a public resolver (ANY is often throttled)
dig +noall +answer ANY app.example.com @8.8.8.8
# more reliable: trace straight to the authoritative nameservers
dig +trace app.example.com
Decide which record stays
If the CNAME is required, for example a CDN vendor asked you to alias www to their hostname, the other records at that name have to go, or move. If the TXT, A, or MX record is the one you actually need, the CNAME has to go instead, replaced with a direct A or AAAA record.
# BEFORE (conflict): both records exist at the same name
www.example.com. CNAME www.example.com.cdn.example.net.
www.example.com. TXT "v=spf1 include:_spf.example.com ~all"
# AFTER, option 1: keep the CNAME, move the TXT off that name
www.example.com. CNAME www.example.com.cdn.example.net.
_spf-check.example.com. TXT "v=spf1 include:_spf.example.com ~all"
# AFTER, option 2: keep the TXT, replace the CNAME with an A record
sub.example.com. A 203.0.113.10
sub.example.com. TXT "v=spf1 include:_spf.example.com ~all"
Apply the change through the Cloudflare API
Delete the record that should not coexist with the CNAME, then, if you still need that data, add it back under a name that is not aliased. Never point a CNAME at the bare zone apex, since SOA and NS records are mandatory there, use CNAME flattening or an ALIAS record for that case instead.
# delete the conflicting record (the TXT, A, or MX id you are removing)
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
# optionally add back a needed record type under a non-aliased name
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"sub.example.com","content":"203.0.113.10","ttl":300}'
How to check it worked
Re-run the same lookups. The record type you removed should now return no answer section, or NXDOMAIN if nothing else lives there, while the record you kept still resolves cleanly. If mail was involved, confirm the MX and TXT records are reachable and match what you expect.
# the removed type should now be empty, the kept type should resolve
dig +noall +answer CNAME sub.example.com
dig +noall +answer TXT sub.example.com
# full answer section should show exactly one RRset for this name
dig sub.example.com
# if mail was involved, confirm MX and TXT are reachable
dig +short MX example.com
dig +short TXT sub.example.com
# re-pull the zone and confirm no name groups CNAME with another type
curl -s "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq '[.result[] | {name, type}]'
The full code
Here is the complete checker in one file for each language. It reads records from the Cloudflare API, groups them by name, reports every CNAME coexistence violation, and, only when you turn dry run off, deletes the conflicting non-CNAME records it found.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Find CNAME coexistence violations in a Cloudflare zone and optionally fix them.
Run whenever you want to audit a zone. Safe to run again and again.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cname_coexistence")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def find_cname_coexistence_violations(records):
"""Pure, no I/O. records: list of {"name": str, "type": str, "id": str}.
Returns a list of {"name": str, "conflicting_ids": list[str], "types": list[str]}.
"""
groups = {}
for rec in records:
key = rec["name"].lower()
groups.setdefault(key, []).append(rec)
violations = []
for name, group in groups.items():
has_cname = any(r["type"] == "CNAME" for r in group)
if has_cname and len(group) > 1:
others = [r for r in group if r["type"] != "CNAME"]
violations.append({
"name": name,
"conflicting_ids": [r["id"] for r in others],
"types": [r["type"] for r in others],
})
return violations
def run():
import requests # imported lazily so the pure function has no dependency at import time
token = os.environ["CLOUDFLARE_API_TOKEN"]
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
r = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers, params={"per_page": 5000}, timeout=30,
)
r.raise_for_status()
records = [
{"name": rec["name"], "type": rec["type"], "id": rec["id"]}
for rec in r.json()["result"]
]
violations = find_cname_coexistence_violations(records)
if not violations:
log.info("No CNAME coexistence violations found.")
return
for v in violations:
log.warning(
"%s has a CNAME plus %s (%d record(s) to remove or relocate)",
v["name"], ", ".join(v["types"]), len(v["conflicting_ids"]),
)
for record_id in v["conflicting_ids"]:
if DRY_RUN:
log.info("Would delete record %s at %s", record_id, v["name"])
continue
del_r = requests.delete(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
headers=headers, timeout=30,
)
del_r.raise_for_status()
log.info("Deleted record %s at %s", record_id, v["name"])
log.info("Done. %d name(s) had a conflict.", len(violations))
if __name__ == "__main__":
run()
/**
* Find CNAME coexistence violations in a Cloudflare zone and optionally fix them.
* Run whenever you want to audit a zone. Safe to run again and again.
*/
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findCnameCoexistenceViolations(records) {
// Pure, no I/O. records: array of { name, type, id }.
// Returns array of { name, conflictingIds, types }.
const groups = new Map();
for (const rec of records) {
const key = rec.name.toLowerCase();
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(rec);
}
const violations = [];
for (const [name, group] of groups) {
const hasCname = group.some((r) => r.type === "CNAME");
if (hasCname && group.length > 1) {
const others = group.filter((r) => r.type !== "CNAME");
violations.push({
name,
conflictingIds: others.map((r) => r.id),
types: others.map((r) => r.type),
});
}
}
return violations;
}
async function run() {
const token = process.env.CLOUDFLARE_API_TOKEN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const listRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?per_page=5000`,
{ headers },
);
if (!listRes.ok) throw new Error(`Cloudflare list returned ${listRes.status}`);
const body = await listRes.json();
const records = body.result.map((rec) => ({ name: rec.name, type: rec.type, id: rec.id }));
const violations = findCnameCoexistenceViolations(records);
if (violations.length === 0) {
console.log("No CNAME coexistence violations found.");
return;
}
for (const v of violations) {
console.warn(
`${v.name} has a CNAME plus ${v.types.join(", ")} (${v.conflictingIds.length} record(s) to remove or relocate)`,
);
for (const recordId of v.conflictingIds) {
if (DRY_RUN) {
console.log(`Would delete record ${recordId} at ${v.name}`);
continue;
}
const delRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${recordId}`,
{ method: "DELETE", headers },
);
if (!delRes.ok) throw new Error(`Cloudflare delete returned ${delRes.status}`);
console.log(`Deleted record ${recordId} at ${v.name}`);
}
}
console.log(`Done. ${violations.length} name(s) had a conflict.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The grouping rule is the part most worth testing, because it decides which records get deleted. Because find_cname_coexistence_violations is pure, the test needs no network and no Cloudflare account. It just feeds in plain record lists and checks the result.
from cname_coexistence import find_cname_coexistence_violations
def rec(name, type_, id_):
return {"name": name, "type": type_, "id": id_}
def test_flags_cname_with_txt_at_same_name():
records = [
rec("app.example.com", "CNAME", "r1"),
rec("app.example.com", "TXT", "r2"),
]
violations = find_cname_coexistence_violations(records)
assert len(violations) == 1
assert violations[0]["name"] == "app.example.com"
assert violations[0]["conflicting_ids"] == ["r2"]
assert violations[0]["types"] == ["TXT"]
def test_clean_zone_has_no_violations():
records = [
rec("www.example.com", "CNAME", "r1"),
rec("example.com", "A", "r2"),
rec("example.com", "MX", "r3"),
]
assert find_cname_coexistence_violations(records) == []
def test_name_matching_is_case_insensitive():
records = [
rec("App.Example.com", "CNAME", "r1"),
rec("app.example.com", "A", "r2"),
]
violations = find_cname_coexistence_violations(records)
assert len(violations) == 1
assert violations[0]["conflicting_ids"] == ["r2"]
def test_lone_cname_is_not_a_violation():
records = [rec("sub.example.com", "CNAME", "r1")]
assert find_cname_coexistence_violations(records) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findCnameCoexistenceViolations } from "./cname-coexistence.js";
const rec = (name, type, id) => ({ name, type, id });
test("flags CNAME with TXT at same name", () => {
const records = [rec("app.example.com", "CNAME", "r1"), rec("app.example.com", "TXT", "r2")];
const violations = findCnameCoexistenceViolations(records);
assert.equal(violations.length, 1);
assert.equal(violations[0].name, "app.example.com");
assert.deepEqual(violations[0].conflictingIds, ["r2"]);
assert.deepEqual(violations[0].types, ["TXT"]);
});
test("clean zone has no violations", () => {
const records = [
rec("www.example.com", "CNAME", "r1"),
rec("example.com", "A", "r2"),
rec("example.com", "MX", "r3"),
];
assert.deepEqual(findCnameCoexistenceViolations(records), []);
});
test("name matching is case insensitive", () => {
const records = [rec("App.Example.com", "CNAME", "r1"), rec("app.example.com", "A", "r2")];
const violations = findCnameCoexistenceViolations(records);
assert.equal(violations.length, 1);
assert.deepEqual(violations[0].conflictingIds, ["r2"]);
});
test("lone CNAME is not a violation", () => {
const records = [rec("sub.example.com", "CNAME", "r1")];
assert.deepEqual(findCnameCoexistenceViolations(records), []);
});
Case studies
The landing page builder that ate the DMARC record
A team pointed go.example.com at a landing page builder with a CNAME, which was fine on its own. Months later, someone added a TXT record for domain verification at the same name for a different tool. The verification silently never worked, because the CNAME always won, and nobody noticed until a support ticket asked why the new tool would not confirm ownership.
Pulling every record type at go.example.com with dig showed the CNAME and the TXT sitting side by side. Moving the verification TXT to a dedicated subdomain fixed it in minutes.
The subdomain that lost its mail
A subdomain used for transactional email had MX and TXT records for SPF. A CDN migration script added a CNAME at that same subdomain to route traffic, without checking what was already there. Outbound mail started failing SPF checks because the TXT record was no longer reliably served.
The fix was to delete the CNAME and replace it with a direct A record pointing at the origin server, which let the MX and TXT records coexist normally again, exactly as email delivery expects.
After the cleanup, every name in the zone has either one CNAME and nothing else, or any mix of A, AAAA, MX, and TXT records but no CNAME. Re-running the audit script on a schedule catches the next accidental conflict before it silently breaks an SPF record or a verification token again.
FAQ
Why can a CNAME not share a name with any other record?
A CNAME tells a resolver that the name is only an alias and the real answer lives somewhere else. DNS rules say no other record type can sit at that same name, because a resolver would not know which record to trust. This is set out in RFC 1035 section 3.6.2 and repeated in RFC 1912 section 2.4.
What usually causes this, and how do I spot it?
It usually happens when a CNAME is added for a marketing tool or CDN on a name that already has a TXT record for SPF, DKIM, or DMARC, or an A or MX record. Check it with dig by querying each record type at the exact same name and looking for more than one type answering.
How do I fix a CNAME coexistence violation?
Decide which record must stay. If the CNAME is required, delete the other records at that name and move anything you still need, like an SPF or DMARC TXT record, to a name that is not aliased. If the other records are required, delete the CNAME and replace it with a direct A or AAAA record instead.
Related field notes
Citations
On the problem:
- RFC 1912, Common DNS Operational and Configuration Errors, section 2.4 on CNAME restrictions. ietf.org/rfc/rfc1912.txt
- RFC 1035, Domain Names, Implementation and Specification, section 3.6.2 on CNAME resource records. ietf.org/rfc/rfc1035.txt
- Cloudflare DNS docs: cannot add DNS records with the same name. developers.cloudflare.com/dns
On the solution:
- Cloudflare DNS docs: cannot add DNS records with the same name, resolution steps. developers.cloudflare.com/dns
- Cloudflare API: DNS Records list, create, and delete methods. developers.cloudflare.com/api
- Cloudflare Learning Center: what is a DNS CNAME record. cloudflare.com/learning/dns
Stuck on a tricky one?
If you have a DNS, domain, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your zone?
If this saved you a broken SPF record or a failed domain verification, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi