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.

Python and Node.js Cloudflare DNS API Safe by default (dry run)
Blue utp cord
Photo by Jordan Harrison on Unsplash
The short answer

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.

Resolver looks up app.example.com CNAME app.example.com -> cdn.vendor.net TXT app.example.com "v=spf1 include:..." same name, two record types Broken zone TXT never served
The CNAME says the name is only an alias, but a TXT record also claims the same name. The zone now serves an unreliable answer instead of rejecting the conflict.

Why it happens

The key insight

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.

List all DNS records via API Group records by name (lowercased) Find CNAME + any other type Violation found? yes no, clean Delete conflict re-add elsewhere
The script only groups and flags records by name. It never guesses which one to keep, that decision is yours before the delete runs.

How to fix it

1

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.

Terminal
check-conflict.sh
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
2

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.

DNS record
records-before-and-after
# 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"
3

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.

Terminal
cloudflare-api.sh
# 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.

Terminal
verify.sh
# 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.

cname_coexistence.py
"""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()
cname-coexistence.js
/**
 * 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.

test_cname_coexistence.py
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) == []
cname-coexistence.test.js
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

Marketing tool

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.

Email migration

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.

What good looks like

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:

  1. RFC 1912, Common DNS Operational and Configuration Errors, section 2.4 on CNAME restrictions. ietf.org/rfc/rfc1912.txt
  2. RFC 1035, Domain Names, Implementation and Specification, section 3.6.2 on CNAME resource records. ietf.org/rfc/rfc1035.txt
  3. Cloudflare DNS docs: cannot add DNS records with the same name. developers.cloudflare.com/dns

On the solution:

  1. Cloudflare DNS docs: cannot add DNS records with the same name, resolution steps. developers.cloudflare.com/dns
  2. Cloudflare API: DNS Records list, create, and delete methods. developers.cloudflare.com/api
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes