Diagnostic Core Records
Duplicate or conflicting records for the same name
One hostname, two answers that do not agree. Most often it is a CNAME record sitting next to an A record at the exact same name, or a stale A record left over from an old server sitting next to the current, correct one. Different resolvers can pick different winners, so the site works for some visitors and fails for others. Here is why it happens, how to spot it, and a script that cleans it up through the Cloudflare API.
A single hostname has two records fighting for the same answer, usually a CNAME plus an A record, or an old IP left behind next to the new one. Query every record type at that name and compare results across resolvers to see the conflict. Then delete every record except the one that should actually be there: keep only the CNAME if the name points to a hosted service, or keep only the A/AAAA record if it should resolve straight to an IP. Two A records at the same name are fine on their own, that is round robin, the problem is only when one of them is stale.
The problem in plain words
Every hostname is supposed to have one clear answer. When you ask for app.example.com, DNS should tell you exactly one thing: either "it is an alias, go look at this other name," or "here is its IP address." A duplicate or conflicting record means the zone is trying to say both at once, or two different versions of the same thing at once.
The most common shape of this is a CNAME record next to an A, AAAA, or TXT record at the identical name. RFC 1034 is explicit that a CNAME cannot coexist with any other record type at the same node, because a resolver has no reliable way to pick a winner. The second common shape is two A records where only one is meant to be there, for example a new hosting provider's IP was added but the old server's IP was never deleted.
Why it happens
- A new hosting provider or CDN asks for a CNAME at a name, and it gets added, but the old A record that pointed straight at a server IP is never deleted.
- A migration script or bulk import copies every record from an old zone into a new one, including a record that was already stale, so the conflict moves with the zone.
- Two people, or two tools, add a record for the same host at different times without checking what already exists, and the DNS host lets both save.
- A decommissioned server's A record is left in place after the traffic moves elsewhere, so it silently sits next to the current, correct IP.
Two A records at the same name are not automatically a bug. www 300 IN A 203.0.113.10 and www 300 IN A 203.0.113.11 together is a completely normal way to do round robin load balancing, and every resolver handles it correctly. The real problem is only when one of the records does not belong there anymore, like a stale IP from a server that was retired months ago, or a CNAME sharing a name with anything else at all.
The fix, as a flow
Query the name for every record type and compare the results. If a CNAME shows up alongside any other type, that is always wrong, RFC 1034 forbids it outright, so delete everything at that name except the single CNAME (or delete the CNAME and keep the others, whichever is actually correct for that host). If you find two or more A or AAAA records, check whether every IP is one you expect. If they are all expected, it is a valid round robin set and nothing needs to change. If one IP is not in your expected list, that is the stale leftover to delete.
In Cloudflare's dashboard this is DNS, Records, find the extra or wrong entry for that host, and delete it. This is also the fix for error code 81053, "An A, AAAA or CNAME record already exists with that host," which Cloudflare shows when you try to add a new record at a name that already has a conflicting one.
How to fix it
Query every record type at the name
Pull each record type for the hostname one at a time, then compare what comes back across a couple of resolvers. A CNAME answer showing up alongside an A answer for the exact same name is always the conflict. Two A answers with different IPs need a second look to tell whether it is intentional round robin or a stale leftover.
dig +noall +answer ANY app.example.com @1.1.1.1
# per type, so you can see exactly what each one returns
dig +short CNAME app.example.com
dig +short A app.example.com
# compare across resolvers to spot inconsistent answers
dig @8.8.8.8 app.example.com
dig @1.1.1.1 app.example.com
dig @ns1.example.com app.example.com
Decide which record is correct
If the name should point to a third-party hosted service, the CNAME is correct and every other record at that exact name has to go. If the name should resolve straight to your own IP, the A or AAAA record is correct and the CNAME has to go. If it is two A records, keep both when every IP is expected, and remove only the one that is stale.
# BEFORE (conflict): CNAME and a leftover A record at the same name
app.example.com. CNAME app.hosting-provider.net.
app.example.com. A 203.0.113.9
# AFTER: keep only the CNAME, delete the leftover A record
app.example.com. CNAME app.hosting-provider.net.
# This is fine and should be left alone: intentional round robin
www.example.com. 300 IN A 203.0.113.10
www.example.com. 300 IN A 203.0.113.11
# This is not fine: one A record is a stale, decommissioned server
www.example.com. 300 IN A 203.0.113.10
www.example.com. 300 IN A 198.51.100.77 # stale, delete this one
Apply the change through the Cloudflare API
Delete the record that does not belong, then, if you need a fresh record in its place, create the single intended one. This is the same fix for Cloudflare error 81053, "An A, AAAA or CNAME record already exists with that host," if you hit it while trying to add a new record.
# delete the leftover or wrong record (the A record id you are removing)
curl -X DELETE \
-H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID"
# then (re)create the single intended record
curl -X POST \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-d '{"type":"CNAME","name":"app","content":"app.hosting-provider.net","ttl":300,"proxied":false}'
How to check it worked
Run the same lookups again and confirm only one record type or one intended set of values comes back for that name, and that it is consistent across resolvers.
# exactly one record type/value should answer now
dig +noall +answer ANY app.example.com @1.1.1.1
dig +short CNAME app.example.com
dig +short A app.example.com
# query the authoritative nameservers directly
dig @ns1.example.com app.example.com
# consistent across public resolvers, no ambiguity about the winner
dig @8.8.8.8 app.example.com
dig @1.1.1.1 app.example.com
# re-fetch via the API and confirm exactly one result for the name
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=app.example.com" \
| jq '.result | length'
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, flags a CNAME sitting with any other type, flags an A or AAAA duplicate whose IP is not on an expected list, and, only when you turn dry run off, deletes the record that does not belong.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect duplicate or conflicting DNS records at a single name, and
optionally repair the zone via Cloudflare.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the name to check, e.g. "app.example.com"
CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
EXPECTED_IPS comma separated list of IPs that are allowed
for A/AAAA records at this name (round robin ok)
DRY_RUN default "true"; set to "false" to actually write
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("duplicate_conflicting_records")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
EXPECTED_IPS = [ip.strip() for ip in os.environ.get("EXPECTED_IPS", "").split(",") if ip.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
def detect_duplicate_conflict(records, expected_ips=None):
"""Pure decision function. No I/O.
records: list of {"type": str, "name": str, "content": str, "id": str}
expected_ips: optional list of IPs that are allowed for A/AAAA records
at this name (an intentional round robin set).
Groups records by exact name. Returns:
{"conflict": True, "reason": "cname_coexistence", "to_remove": [ids]}
when a CNAME is present alongside any other type at that name.
{"conflict": True, "reason": "ambiguous_duplicate_ip", "to_remove": [ids]}
when 2+ A or 2+ AAAA records exist at the name and at least one
content value is not in expected_ips.
{"conflict": False, "reason": "", "to_remove": []} otherwise.
"""
expected_ips = set(expected_ips or [])
groups = {}
for rec in records:
groups.setdefault(rec["name"], []).append(rec)
for name, group in groups.items():
has_cname = any(r["type"] == "CNAME" for r in group)
if has_cname and len(group) > 1:
to_remove = [r["id"] for r in group if r["type"] != "CNAME"]
return {"conflict": True, "reason": "cname_coexistence", "to_remove": to_remove}
for rtype in ("A", "AAAA"):
same_type = [r for r in group if r["type"] == rtype]
if len(same_type) < 2:
continue
if expected_ips and any(r["content"] not in expected_ips for r in same_type):
to_remove = [r["id"] for r in same_type if r["content"] not in expected_ips]
return {"conflict": True, "reason": "ambiguous_duplicate_ip", "to_remove": to_remove}
return {"conflict": False, "reason": "", "to_remove": []}
def list_zone_records(name=None):
"""List DNS records in the Cloudflare zone, optionally filtered by name."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
params = {"per_page": 5000}
if name:
params["name"] = name
r = requests.get(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
headers=headers, params=params, timeout=30,
)
r.raise_for_status()
return [
{"name": rec["name"], "type": rec["type"], "content": rec["content"], "id": rec["id"]}
for rec in r.json()["result"]
]
def delete_record(record_id):
"""Delete a single DNS record through the Cloudflare API."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
if DRY_RUN:
log.info("[dry run] would delete record %s", record_id)
return
requests.delete(url, headers=headers, timeout=30).raise_for_status()
log.info("Deleted record %s", record_id)
def run():
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Nothing to check for %s.", DNS_DOMAIN)
return
records = list_zone_records(DNS_DOMAIN)
result = detect_duplicate_conflict(records, EXPECTED_IPS)
if not result["conflict"]:
log.info("No duplicate or conflicting records found for %s.", DNS_DOMAIN)
return
log.warning(
"%s has a conflict (%s), %d record(s) to remove",
DNS_DOMAIN, result["reason"], len(result["to_remove"]),
)
for record_id in result["to_remove"]:
delete_record(record_id)
log.info("Done.")
if __name__ == "__main__":
run()
/**
* Detect duplicate or conflicting DNS records at a single name, and
* optionally repair the zone via Cloudflare.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the name to check, e.g. "app.example.com"
* CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
* CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
* EXPECTED_IPS comma separated list of IPs allowed for
* A/AAAA records at this name (round robin ok)
* DRY_RUN default "true"; set to "false" to actually write
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const EXPECTED_IPS = (process.env.EXPECTED_IPS || "")
.split(",")
.map((ip) => ip.trim())
.filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
export function detectDuplicateConflict(records, expectedIps = []) {
// Pure decision function. No I/O.
// records: array of { type, name, content, id }
// expectedIps: optional array of IPs allowed for A/AAAA records at this
// name (an intentional round robin set).
const expected = new Set(expectedIps);
const groups = new Map();
for (const rec of records) {
if (!groups.has(rec.name)) groups.set(rec.name, []);
groups.get(rec.name).push(rec);
}
for (const [, group] of groups) {
const hasCname = group.some((r) => r.type === "CNAME");
if (hasCname && group.length > 1) {
const toRemove = group.filter((r) => r.type !== "CNAME").map((r) => r.id);
return { conflict: true, reason: "cname_coexistence", toRemove };
}
for (const rtype of ["A", "AAAA"]) {
const sameType = group.filter((r) => r.type === rtype);
if (sameType.length < 2) continue;
if (expected.size > 0 && sameType.some((r) => !expected.has(r.content))) {
const toRemove = sameType.filter((r) => !expected.has(r.content)).map((r) => r.id);
return { conflict: true, reason: "ambiguous_duplicate_ip", toRemove };
}
}
}
return { conflict: false, reason: "", toRemove: [] };
}
async function listZoneRecords(name) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const params = new URLSearchParams({ per_page: "5000" });
if (name) params.set("name", name);
const res = await fetch(
`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
{ headers },
);
if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
const body = await res.json();
return body.result.map((rec) => ({
name: rec.name, type: rec.type, content: rec.content, id: rec.id,
}));
}
async function deleteRecord(recordId) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
if (DRY_RUN) {
console.log(`[dry run] would delete record ${recordId}`);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "DELETE",
headers,
});
if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
console.log(`Deleted record ${recordId}`);
}
async function run() {
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn(`No Cloudflare credentials set. Nothing to check for ${DNS_DOMAIN}.`);
return;
}
const records = await listZoneRecords(DNS_DOMAIN);
const result = detectDuplicateConflict(records, EXPECTED_IPS);
if (!result.conflict) {
console.log(`No duplicate or conflicting records found for ${DNS_DOMAIN}.`);
return;
}
console.warn(
`${DNS_DOMAIN} has a conflict (${result.reason}), ${result.toRemove.length} record(s) to remove`,
);
for (const recordId of result.toRemove) {
await deleteRecord(recordId);
}
console.log("Done.");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
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 detect_duplicate_conflict is pure, the test needs no network and no Cloudflare account. It just feeds in plain record lists and checks the result.
from duplicate_conflicting_records import detect_duplicate_conflict
def rec(name, type_, content, id_):
return {"name": name, "type": type_, "content": content, "id": id_}
def test_flags_cname_with_a_record_at_same_name():
records = [
rec("app.example.com", "CNAME", "app.hosting-provider.net", "r1"),
rec("app.example.com", "A", "203.0.113.9", "r2"),
]
result = detect_duplicate_conflict(records)
assert result["conflict"] is True
assert result["reason"] == "cname_coexistence"
assert result["to_remove"] == ["r2"]
def test_round_robin_a_records_are_not_a_conflict():
records = [
rec("www.example.com", "A", "203.0.113.10", "r1"),
rec("www.example.com", "A", "203.0.113.11", "r2"),
]
expected_ips = ["203.0.113.10", "203.0.113.11"]
result = detect_duplicate_conflict(records, expected_ips)
assert result["conflict"] is False
def test_flags_stale_ip_among_duplicate_a_records():
records = [
rec("www.example.com", "A", "203.0.113.10", "r1"),
rec("www.example.com", "A", "198.51.100.77", "r2"),
]
expected_ips = ["203.0.113.10"]
result = detect_duplicate_conflict(records, expected_ips)
assert result["conflict"] is True
assert result["reason"] == "ambiguous_duplicate_ip"
assert result["to_remove"] == ["r2"]
def test_clean_zone_has_no_conflict():
records = [
rec("www.example.com", "CNAME", "www.hosting-provider.net", "r1"),
rec("example.com", "A", "203.0.113.10", "r2"),
rec("example.com", "MX", "mail.example.com", "r3"),
]
assert detect_duplicate_conflict(records)["conflict"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectDuplicateConflict } from "./duplicate-conflicting-records.js";
const rec = (name, type, content, id) => ({ name, type, content, id });
test("flags CNAME with A record at same name", () => {
const records = [
rec("app.example.com", "CNAME", "app.hosting-provider.net", "r1"),
rec("app.example.com", "A", "203.0.113.9", "r2"),
];
const result = detectDuplicateConflict(records);
assert.equal(result.conflict, true);
assert.equal(result.reason, "cname_coexistence");
assert.deepEqual(result.toRemove, ["r2"]);
});
test("round robin A records are not a conflict", () => {
const records = [
rec("www.example.com", "A", "203.0.113.10", "r1"),
rec("www.example.com", "A", "203.0.113.11", "r2"),
];
const expectedIps = ["203.0.113.10", "203.0.113.11"];
const result = detectDuplicateConflict(records, expectedIps);
assert.equal(result.conflict, false);
});
test("flags stale IP among duplicate A records", () => {
const records = [
rec("www.example.com", "A", "203.0.113.10", "r1"),
rec("www.example.com", "A", "198.51.100.77", "r2"),
];
const expectedIps = ["203.0.113.10"];
const result = detectDuplicateConflict(records, expectedIps);
assert.equal(result.conflict, true);
assert.equal(result.reason, "ambiguous_duplicate_ip");
assert.deepEqual(result.toRemove, ["r2"]);
});
test("clean zone has no conflict", () => {
const records = [
rec("www.example.com", "CNAME", "www.hosting-provider.net", "r1"),
rec("example.com", "A", "203.0.113.10", "r2"),
rec("example.com", "MX", "mail.example.com", "r3"),
];
assert.equal(detectDuplicateConflict(records).conflict, false);
});
Case studies
The launch that half the internet never saw
A store moved its storefront subdomain to a CDN and added the CNAME the CDN asked for. Nobody deleted the old A record that had pointed straight at the original server. For weeks, some visitors landed on the new fast CDN and others landed on the old server, which was still up but no longer being updated with new products.
Querying the name for every record type showed both a CNAME and an A record answering. Deleting the leftover A record made every resolver agree on the CDN, and the mismatched product catalog complaints stopped the same day.
The old box that would not stay dead
A team retired an application server and pointed its subdomain's A record at the replacement. But a second A record from before the move, still carrying the decommissioned server's IP, was never removed. Health checks passed because the new IP resolved fine most of the time, yet a slice of traffic kept hitting a dead machine and timing out.
Listing the zone through the Cloudflare API and grouping by name surfaced the pair immediately. One record had an IP nobody recognized against the expected list, that was the one to delete, and the round robin pair left behind was the two current, load balanced servers.
After the cleanup, every hostname resolves the same way no matter which resolver you ask, whether that is dig @8.8.8.8, dig @1.1.1.1, or the domain's own authoritative nameservers. A CNAME name has nothing else sitting with it, and any A or AAAA record set only contains IPs you actually expect. Re-running the checker after any migration or bulk import catches the next leftover before it causes a split-brain outage.
FAQ
Why does a CNAME record conflict with an A record at the same name?
A CNAME says a name is only an alias and the real answer lives at another name. RFC 1034 does not allow any other record type to also exist at that same name, because a resolver would not know which one to trust. When a CNAME and an A record both sit at the same host, the DNS host is holding two conflicting answers at once.
Are two A records at the same name always a problem?
No. Two or more A records at the same name pointing at different IPs is a normal way to do round robin load balancing, and DNS handles it fine. It only becomes a problem when one of those IPs is stale, such as a leftover from a decommissioned server, while the other is the current, intended address.
How do I fix duplicate or conflicting records?
Decide which single answer is correct for that name. If the name should point to a hosted service, keep only the CNAME and delete the A, AAAA, or other records at that exact name. If the name should resolve straight to an IP, keep the A or AAAA record and delete the CNAME. If two A records are an intentional round robin, leave them both and only remove the one that is genuinely stale.
Related field notes
Citations
On the problem:
- RFC 1034, Domain Names, Concepts and Facilities, section 3.6.2 on CNAME resource records. rfc-editor.org/rfc/rfc1034
- UltraDNS Support: why can't I have a CNAME match another record. dns.ultraproducts.support
- Simon Painter: CNAME rules in DNS, what you need to know. simonpainter.com/cname-rules
On the solution:
- Cloudflare Community: fixing "An A, AAAA or CNAME record already exists with that host" (code 81053). community.cloudflare.com
- Cloudflare API: DNS Records, List method. developers.cloudflare.com/api
- Cloudflare docs: Manage DNS records. developers.cloudflare.com/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 split-brain outage or a confusing support ticket, 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