Repair Core Records

CNAME to A record type change fails on existing RRSet

You changed a hostname from a CNAME to a plain A record, your deploy tool said it worked, and the DNS host still rejects it or still serves the old alias. This happens because DNS will never let a CNAME and an A record share the same name, even for a single moment. Here is why the swap fails and a script that fixes it without the race.

Python and Node.js Cloudflare DNS API Safe by default (dry run)
A bunch of wires that are connected to a server
Photo by Lightsaber Collection on Unsplash
The short answer

DNS forbids a CNAME from sharing a name with any other record type. When a script deletes the old CNAME and creates the new A record as two separate calls, the create can race ahead of the delete and get rejected because the CNAME is still there. Fix it by turning the change into one atomic update on the existing record's ID, so the CNAME and the A record are never both present at once.

The problem in plain words

A hostname can only mean one thing in DNS. Either it is an alias that points somewhere else with a CNAME, or it is a real address with an A record. It cannot be both, not even for a second. This rule comes straight from RFC 1035 and is repeated as a common error to avoid in RFC 1912.

Most deploy tools change a record type by removing the old one and adding the new one as two separate API calls. If those two calls are not strictly ordered, or if the second one is sent before the first one is confirmed, the DNS host still sees the old CNAME sitting there when the new A record arrives. It rejects the new record with a same-name conflict, and your change fails halfway through.

Deploy tool wants CNAME to A DELETE CNAME app.example.com POST A record sent too soon CNAME still live when A arrives 400 same-name conflict CNAME already exists with that host
The delete and the create are sent as two separate calls. If the create arrives before the delete is confirmed, the DNS host still sees the CNAME and rejects the A record.

Why it happens

The key insight

The failure is not about the record values being wrong. It is about timing. Two independent calls, a delete and a create, leave a window where the old record and the new record could both exist, and DNS refuses to allow that window. Collapsing the change into one call on the same record ID removes the window entirely.

The fix, as a flow

Instead of asking "did the delete finish yet," change the question to "what is live right now, and does it match what I want." A script reads the current record at the name, compares it to the desired record, and if the only thing at that name is a CNAME where an A record belongs, it overwrites that same record in place with one call. No delete, no create, no race.

Read live record GET dns_records?name= Compare to desired type A, given IP One CNAME conflict? yes no match, no conflict: noop or create PUT same record id type becomes A, one call One record, no window
The script reads the live state first, finds the single conflicting CNAME, then sends one PUT to that record's own ID. The CNAME and the A record never coexist, even briefly.

How to fix it

1

Confirm what is actually live

Before touching anything, check both record types at the name. If the CNAME still returns a target when you expected an A record with an IP, the old record was never removed.

Terminal
check-live.sh
dig +short CNAME app.example.com
dig +short A app.example.com
2

Ask the authoritative nameserver directly

Your resolver may still be caching the old answer. Query the zone's own nameserver to see the true current state, with no caching in the way.

Terminal
check-authoritative.sh
dig @ns1.example.com app.example.com CNAME +noall +answer
dig @ns1.example.com app.example.com A +noall +answer
3

Reproduce the API rejection

Sending the create while the CNAME is still present returns a 400 with a code that names the conflict directly. Cloudflare uses 81057 or 81053 for "Record already exists," and 81054 for "A CNAME already exists with that host" on the reverse swap.

Terminal
reproduce-conflict.sh
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  --data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300}'
4

Do not delete then create. Overwrite in place

The safe fix is one PUT call to the existing record's own ID. It reuses the record instead of removing one and adding another, so there is never a moment where both a CNAME and an A record exist at the name.

DNS record
overwrite-in-place.sh
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records/<existing_record_id>" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  --data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300}'

If your tool truly must delete and create as two calls, the fallback is to sequence them strictly: call DELETE /zones/{zone_id}/dns_records/{cname_record_id} and wait for a 200, then only after that succeeds call POST /zones/{zone_id}/dns_records with the new A record. The single PUT above is preferred because it removes the race instead of just managing it.

How to check it worked

Run the same checks from step 1 again. The CNAME should be gone, and only the A record should answer.

Terminal
verify.sh
dig +short CNAME app.example.com
# expect: empty output, nothing returned

dig +short A app.example.com
# expect: 203.0.113.10

dig @ns1.example.com app.example.com A +noall +answer
# expect: one A record, no CNAME alongside it

A good result is exactly one record type at that name, every resolver agreeing on the IP, and the same POST or PUT call from step 3 now returning HTTP 200 with "success":true and a type field of "A".

The full code

Here is a complete script for each language. It reads the live records at a name from Cloudflare, decides whether the change is a noop, a plain create, or an overwrite of a conflicting record, and only writes when told to. The decision is a pure function with no network calls, so it is easy to test on its own.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

fix_cname_to_a.py
"""Detect a CNAME left behind at a name where an A record is wanted, and
repair it with one atomic Cloudflare PUT instead of a delete-then-create race.
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("fix_cname_to_a")


def plan_rrset_change(live_records, desired):
    """Pure decision. No I/O. live_records: list of dicts with id, type,
    content for the name. desired: dict with name, type, content, ttl.
    Returns one of:
      {"action": "noop"}
      {"action": "overwrite", "record_id": }
      {"action": "create"}
    """
    for rec in live_records:
        if rec["type"] == desired["type"] and rec["content"] == desired["content"]:
            return {"action": "noop"}

    conflicting = [r for r in live_records if r["type"] != desired["type"]]
    if len(conflicting) == 1:
        return {"action": "overwrite", "record_id": conflicting[0]["id"]}

    if not live_records:
        return {"action": "create"}

    return {"action": "noop"}


def run():
    # Imported lazily so this module can be unit tested with no network
    # and no DNS libraries installed.
    import dns.resolver
    import requests

    domain = os.environ["DNS_DOMAIN"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]
    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    desired_ip = os.environ["DESIRED_A_RECORD_IP"]
    ttl = int(os.environ.get("DESIRED_TTL", "300"))
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
    base = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"

    try:
        answers = dns.resolver.resolve(domain, "CNAME")
        log.info("Resolver still sees a CNAME for %s: %s", domain, answers[0].target)
    except Exception:
        log.info("Resolver shows no CNAME for %s", domain)

    resp = requests.get(base, headers=headers, params={"name": domain}, timeout=30)
    resp.raise_for_status()
    live_records = [
        {"id": r["id"], "type": r["type"], "content": r["content"]}
        for r in resp.json()["result"]
    ]

    desired = {"name": domain, "type": "A", "content": desired_ip, "ttl": ttl}
    plan = plan_rrset_change(live_records, desired)
    log.info("Plan for %s: %s", domain, plan)

    if plan["action"] == "noop":
        log.info("Nothing to do. %s already matches.", domain)
        return

    if dry_run:
        log.info("Dry run is on. Would apply: %s", plan)
        return

    if plan["action"] == "overwrite":
        record_id = plan["record_id"]
        r = requests.put(
            f"{base}/{record_id}",
            headers=headers,
            json={"type": "A", "name": domain, "content": desired_ip, "ttl": ttl},
            timeout=30,
        )
        r.raise_for_status()
        log.info("Overwrote record %s in place. Now type A, content %s.", record_id, desired_ip)
    elif plan["action"] == "create":
        r = requests.post(
            base,
            headers=headers,
            json={"type": "A", "name": domain, "content": desired_ip, "ttl": ttl},
            timeout=30,
        )
        r.raise_for_status()
        log.info("Created new A record for %s pointing to %s.", domain, desired_ip)


if __name__ == "__main__":
    run()
fix-cname-to-a.js
/**
 * Detect a CNAME left behind at a name where an A record is wanted, and
 * repair it with one atomic Cloudflare PUT instead of a delete-then-create
 * race. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

export function planRrsetChange(liveRecords, desired) {
  for (const rec of liveRecords) {
    if (rec.type === desired.type && rec.content === desired.content) {
      return { action: "noop" };
    }
  }

  const conflicting = liveRecords.filter((r) => r.type !== desired.type);
  if (conflicting.length === 1) {
    return { action: "overwrite", recordId: conflicting[0].id };
  }

  if (liveRecords.length === 0) {
    return { action: "create" };
  }

  return { action: "noop" };
}

export async function run() {
  // Imported lazily so this module can be unit tested with no network
  // and no DNS libraries installed.
  const dns = await import("node:dns/promises");

  const domain = process.env.DNS_DOMAIN;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const desiredIp = process.env.DESIRED_A_RECORD_IP;
  const ttl = Number(process.env.DESIRED_TTL || 300);
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
  const base = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;

  try {
    const answers = await dns.resolveCname(domain);
    console.log(`Resolver still sees a CNAME for ${domain}: ${answers[0]}`);
  } catch {
    console.log(`Resolver shows no CNAME for ${domain}`);
  }

  const listRes = await fetch(`${base}?name=${encodeURIComponent(domain)}`, { headers });
  if (!listRes.ok) throw new Error(`Cloudflare list returned ${listRes.status}`);
  const listBody = await listRes.json();
  const liveRecords = listBody.result.map((r) => ({ id: r.id, type: r.type, content: r.content }));

  const desired = { name: domain, type: "A", content: desiredIp, ttl };
  const plan = planRrsetChange(liveRecords, desired);
  console.log(`Plan for ${domain}:`, plan);

  if (plan.action === "noop") {
    console.log(`Nothing to do. ${domain} already matches.`);
    return;
  }

  if (dryRun) {
    console.log(`Dry run is on. Would apply:`, plan);
    return;
  }

  if (plan.action === "overwrite") {
    const putRes = await fetch(`${base}/${plan.recordId}`, {
      method: "PUT",
      headers,
      body: JSON.stringify({ type: "A", name: domain, content: desiredIp, ttl }),
    });
    if (!putRes.ok) throw new Error(`Cloudflare update returned ${putRes.status}`);
    console.log(`Overwrote record ${plan.recordId} in place. Now type A, content ${desiredIp}.`);
  } else if (plan.action === "create") {
    const postRes = await fetch(base, {
      method: "POST",
      headers,
      body: JSON.stringify({ type: "A", name: domain, content: desiredIp, ttl }),
    });
    if (!postRes.ok) throw new Error(`Cloudflare create returned ${postRes.status}`);
    console.log(`Created new A record for ${domain} pointing to ${desiredIp}.`);
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The decision function is the part worth testing on its own, since it decides whether a live DNS record gets overwritten. It takes plain lists and dicts, makes no network call, and is fast to run.

test_plan_rrset.py
from fix_cname_to_a import plan_rrset_change


def desired(**over):
    base = {"name": "app.example.com", "type": "A", "content": "203.0.113.10", "ttl": 300}
    base.update(over)
    return base


def test_noop_when_already_matching():
    live = [{"id": "rec_1", "type": "A", "content": "203.0.113.10"}]
    assert plan_rrset_change(live, desired()) == {"action": "noop"}


def test_overwrite_when_one_conflicting_cname():
    live = [{"id": "rec_9", "type": "CNAME", "content": "old-target.example.net"}]
    assert plan_rrset_change(live, desired()) == {"action": "overwrite", "record_id": "rec_9"}


def test_create_when_nothing_exists():
    assert plan_rrset_change([], desired()) == {"action": "create"}


def test_noop_when_multiple_conflicting_records():
    live = [
        {"id": "rec_1", "type": "TXT", "content": "v=spf1 -all"},
        {"id": "rec_2", "type": "MX", "content": "mail.example.com"},
    ]
    assert plan_rrset_change(live, desired())["action"] == "noop"
plan-rrset.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { planRrsetChange } from "./fix-cname-to-a.js";

const desired = (over = {}) => ({
  name: "app.example.com",
  type: "A",
  content: "203.0.113.10",
  ttl: 300,
  ...over,
});

test("noop when already matching", () => {
  const live = [{ id: "rec_1", type: "A", content: "203.0.113.10" }];
  assert.deepEqual(planRrsetChange(live, desired()), { action: "noop" });
});

test("overwrite when one conflicting cname", () => {
  const live = [{ id: "rec_9", type: "CNAME", content: "old-target.example.net" }];
  assert.deepEqual(planRrsetChange(live, desired()), { action: "overwrite", recordId: "rec_9" });
});

test("create when nothing exists", () => {
  assert.deepEqual(planRrsetChange([], desired()), { action: "create" });
});

test("noop when multiple conflicting records", () => {
  const live = [
    { id: "rec_1", type: "TXT", content: "v=spf1 -all" },
    { id: "rec_2", type: "MX", content: "mail.example.com" },
  ];
  assert.equal(planRrsetChange(live, desired()).action, "noop");
});

Case studies

Terraform

The apply that half finished every time

A team moved a staging subdomain off a CDN CNAME to a direct A record. Terraform's plan showed one resource destroyed and one created. On every apply, the create failed with Cloudflare's "A CNAME already exists with that host" error, because the provider sent the create before the destroy had fully propagated on Cloudflare's side.

They stopped modeling the change as destroy plus create and instead pointed both the old and new resource blocks at the same record ID so Terraform issued a single update. The next apply succeeded on the first try.

external-dns

The Kubernetes ingress that kept flapping

A cluster's external-dns controller was reconciling an ingress from a CNAME to an A record on every sync loop, since the change never fully committed and the controller saw a diff again next cycle. Each attempt logged the same 81057 conflict.

Switching the annotation so the controller updated the existing record instead of tearing it down first ended the loop. The dig checks afterward showed one clean A record with no leftover CNAME.

What good looks like

After the fix, there is exactly one record at the name, the deploy tool's own conflict-detection call returns success on the same request that used to fail, and dig from both a normal resolver and the authoritative nameserver agree on the answer. No delete-then-create race left to fail on the next deploy.

FAQ

Why does changing a CNAME to an A record fail with a conflict?

DNS will not let a CNAME and any other record type sit at the same name at once. If your tool sends the create for the new A record before the delete of the old CNAME has finished, the DNS host still sees the CNAME and rejects the new record as a same-name conflict.

How do I know if this is what is happening to me?

Run dig +short CNAME on the name. If it still returns a target after your tool reported success, the old record was never fully removed before the new one was attempted. The API error usually names the existing record type directly, such as Cloudflare's 81057 or 81054 codes.

What is the actual fix?

Stop sending a delete and a create as two separate calls. Either wait for the delete to return success before sending the create, or better, send one PUT to the existing record's ID that overwrites its type and value. That way both records never exist at the same name at the same time.

Related field notes

Citations

On the problem:

  1. RFC 1035 section 3.6.2, the rule that a CNAME cannot coexist with other record types at the same name. ietf.org/rfc/rfc1035.txt
  2. RFC 1912, Common DNS Operational and Configuration Errors, section 2.4. ietf.org/rfc/rfc1912.txt
  3. Cloudflare docs on the "Cannot add DNS records with the same name" error. developers.cloudflare.com/dns

On the solution:

  1. cloudflare/terraform-provider-cloudflare issue #750, the create-before-destroy race producing error 81054. github.com/cloudflare/terraform-provider-cloudflare/issues/750
  2. Cloudflare API reference for updating (overwriting) a DNS record with PUT. developers.cloudflare.com/api
  3. Cloudflare API reference for editing a DNS record with PATCH. developers.cloudflare.com/api

Stuck on a tricky one?

If you have a DNS cutover, a Terraform reconciliation race, or a zone that will not converge the way you expect, 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 DNS conflict?

If this saved you a broken deploy or a confusing on-call page, 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