Reconciler Nameserver / Delegation

Partial nameserver cutover serves split answers

You moved your domain to a new DNS host. Some people now see the new website and new email settings. Other people, on a different network, still see the old ones. Nobody changed anything twice, yet the same domain name gives two different answers at the same time. Here is why that happens during a nameserver switch, and a small script that checks every nameserver for your domain and tells you exactly where the split is.

Python and Node.js Read-only checker Safe by default (dry run)
Working at many monitors
Photo by Esten Erbol on Unsplash
The short answer

The domain's delegation at the registrar still lists both the old provider's nameservers and the new provider's nameservers, or some resolvers still have the old nameserver list cached. Every nameserver that is listed is treated as equally correct, so different resolvers around the internet ask whichever one they reach first, and if the old and new zones do not hold the exact same records, those resolvers get different answers for the same name. Run a small Python or Node.js checker that lists every nameserver for the domain, queries each one directly for the records you care about, and flags any nameserver whose answer disagrees with the rest. Full code, tests, and a dry run guard are below.

The problem in plain words

A domain name is not looked up in one place. The registrar tells the world which nameservers are "the truth" for a domain. When you switch DNS providers, you are supposed to remove the old nameservers from that list and put only the new ones in.

The trouble starts when that switch is only half done. Maybe the registrar still lists both the old and the new nameservers together. Maybe the registrar list is already clean, but some resolver out there cached the old nameserver list and has not asked again yet. Either way, there are now two nameservers that both claim to be correct for your domain, and they do not agree. One resolver asks the old one and gets old answers. Another resolver asks the new one and gets new answers. Both answers are "valid" as far as DNS is concerned. The result just depends on luck.

Resolver A asks example.com Resolver B asks example.com ns1.oldhost.com still delegated lena.ns.cloudflare.com new provider Old IP, old MX, no new TXT stale answer New IP, new MX, new TXT current answer answers disagree
Two nameservers both listed as authoritative, but their zones do not match, so two resolvers get two different truths for the same domain.

Why it happens

RFC 1034 expects every nameserver listed for a domain to give the same answer, since resolvers pick whichever one answers fastest and trust it equally. A partial cutover breaks that assumption in one of a few common ways:

This is sometimes called a lame delegation or an inconsistent delegation, and it is a known, well documented failure mode of nameserver migrations, not a bug in any one provider.

The key insight

DNS has no concept of "the newer nameserver wins." Every nameserver listed for a domain is treated as equally correct. If two of them disagree, the internet does not pick a winner for you, it just splits traffic between them at random. The fix is not to wait, it is to make sure only one set of nameservers is listed, and that its zone already has everything the old zone had.

The fix, as a flow

There are two moves, and order matters. First, make the new provider's zone byte-for-byte identical to the old one for every record actually in use, so it does not matter which nameserver a resolver happens to reach. Second, once that is confirmed, go to the registrar and set the nameserver list to only the new provider, removing every old-provider entry. Lowering the old zone's TTLs a day or two before the cutover means any leftover caches expire fast.

Copy every record old zone to new zone Diff new vs old A, AAAA, CNAME, MX, TXT Zones match? no missing records Registrar: remove old provider nameservers One nameserver set every resolver agrees
Match the zones first, then cut the registrar delegation down to one provider, then confirm every resolver sees the same thing.

How to fix it

1

Find every nameserver currently delegated

Ask a normal resolver which nameservers are listed for the domain. If it shows both the old provider's names and the new provider's names, that is the partial cutover.

Terminal
check-ns.sh
dig +short NS example.com

# a bad result looks like both providers listed together:
# ns1.oldhost.com.
# ns2.oldhost.com.
# lena.ns.cloudflare.com.
# walt.ns.cloudflare.com.
2

Compare what the TLD hands out with what a resolver caches

Query a TLD server directly and compare it to a normal resolver. If they disagree, the registrar delegation and a cached copy are out of sync, which is exactly the split you are chasing.

Terminal
check-tld.sh
# what the TLD server currently delegates
dig +short NS example.com @a.gtld-servers.net

# what a normal recursive resolver currently returns
dig +short NS example.com
3

Query each nameserver directly and diff the records

Ask the old nameserver and the new nameserver the same question for every record type you care about. A bad result looks like the old nameserver returning the previous IP, MX, or TXT value while the new one returns updated values, or missing a record entirely, such as a TXT record used for SPF or DKIM that never got copied over.

Terminal
diff-ns.sh
dig @ns1.oldhost.com A example.com +short
dig @lena.ns.cloudflare.com A example.com +short

dig @ns1.oldhost.com MX example.com +short
dig @lena.ns.cloudflare.com MX example.com +short

dig @ns1.oldhost.com TXT example.com +short
dig @lena.ns.cloudflare.com TXT example.com +short
4

Make the new zone match the old zone, record for record

Before touching the registrar, add every A, AAAA, CNAME, MX, TXT, SRV, and NS record the old provider has to the new provider's zone, with the same values. Lower the old zone's TTL beforehand so any leftover caches expire quickly once the switch is final.

DNS record
records to copy exactly
TXT  @   "v=spf1 include:_spf.google.com ~all"
MX   @   10 mail.oldhost.com
A    @   203.0.113.5
TTL  300 (set on the old zone a day or two before the cutover)
5

Remove the old nameservers at the registrar

This part is a registrar-portal action, not something an API script can do. Log into the registrar, open the nameserver settings for the domain, and set the list to only the new provider's nameservers. Delete every old-provider entry so there is exactly one, unambiguous delegation.

Registrar nameserver list
before and after
Before (partial, two providers delegated):
  ns1.oldhost.com
  ns2.oldhost.com
  lena.ns.cloudflare.com
  walt.ns.cloudflare.com

After (clean, one provider delegated):
  lena.ns.cloudflare.com
  walt.ns.cloudflare.com

How to check it worked

Query several public resolvers and confirm every one returns only the new provider's nameservers, then confirm every nameserver gives the same records.

Terminal
verify.sh
dig @8.8.8.8 NS example.com +short
dig @1.1.1.1 NS example.com +short
dig @9.9.9.9 NS example.com +short
# good result: every line above shows only the new provider,
# no old-provider names anywhere

dig @lena.ns.cloudflare.com A example.com +short
dig @walt.ns.cloudflare.com A example.com +short
# good result: identical values from both new nameservers

dig +trace example.com
# good result: the trace only follows the new NS chain from the TLD down

The full code

Here is a complete checker in one file for each language. It resolves the domain's nameservers, queries each one directly for the records you care about, and reports which nameserver disagrees with the rest. It can also push missing records to the new provider through the Cloudflare API, guarded by a dry run flag, since removing the old nameservers still has to happen at the registrar by hand.

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

check_split_answers.py
"""Find a partial nameserver cutover: query every authoritative nameserver
for a domain directly and flag any one whose records disagree with the rest.
Optionally push missing records to the new provider through the Cloudflare API.
Safe to run again and again. Read-only unless DRY_RUN is turned off.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_split_answers")

RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT"]


def diff_nameserver_answers(ns_records):
    """Pure, I/O free. ns_records maps nameserver hostname to a dict of
    record_type -> sorted list of rdata strings. Returns a dict of
    record_type -> list of nameserver hostnames that disagree with the
    majority answer for that type. Empty dict when everything agrees.
    """
    disagreements = {}
    hosts = list(ns_records.keys())
    if len(hosts) < 2:
        return disagreements

    for rtype in RECORD_TYPES:
        answers = {host: tuple(ns_records[host].get(rtype, [])) for host in hosts}
        counts = {}
        for value in answers.values():
            counts[value] = counts.get(value, 0) + 1
        if len(counts) <= 1:
            continue  # every nameserver agrees for this record type
        majority_value = max(counts, key=counts.get)
        outliers = [host for host, value in answers.items() if value != majority_value]
        if outliers:
            disagreements[rtype] = sorted(outliers)
    return disagreements


def run():
    import dns.resolver  # dnspython, imported lazily so the pure function above needs no network
    import requests

    domain = os.environ["DNS_DOMAIN"]
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
    cf_token = os.environ.get("CLOUDFLARE_API_TOKEN")
    cf_zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")

    log.info("Resolving nameservers for %s", domain)
    ns_hosts = sorted(str(r.target).rstrip(".") for r in dns.resolver.resolve(domain, "NS"))
    log.info("Found %d nameserver(s): %s", len(ns_hosts), ", ".join(ns_hosts))

    ns_records = {}
    for host in ns_hosts:
        resolver = dns.resolver.Resolver(configure=False)
        try:
            ns_ip = str(dns.resolver.resolve(host, "A")[0])
        except Exception as exc:
            log.warning("Could not resolve nameserver %s: %s", host, exc)
            continue
        resolver.nameservers = [ns_ip]
        records = {}
        for rtype in RECORD_TYPES:
            try:
                answer = resolver.resolve(domain, rtype)
                records[rtype] = sorted(str(rr).strip('"') for rr in answer)
            except Exception:
                records[rtype] = []
        ns_records[host] = records

    disagreements = diff_nameserver_answers(ns_records)
    if not disagreements:
        log.info("All nameservers agree. No split detected.")
        return

    for rtype, outliers in disagreements.items():
        log.warning("Record type %s disagrees on: %s", rtype, ", ".join(outliers))

    if dry_run or not (cf_token and cf_zone_id):
        log.info("Dry run (or missing Cloudflare credentials): not writing any records.")
        return

    # Best effort repair: copy the majority answer for each mismatched type
    # into the new provider's zone through the Cloudflare API.
    headers = {"Authorization": f"Bearer {cf_token}", "Content-Type": "application/json"}
    for rtype in disagreements:
        majority_host = next(h for h in ns_hosts if h not in disagreements[rtype])
        values = ns_records[majority_host].get(rtype, [])
        for value in values:
            log.info("Would create/update %s record %s -> %s at Cloudflare", rtype, domain, value)
            requests.post(
                f"https://api.cloudflare.com/client/v4/zones/{cf_zone_id}/dns_records",
                json={"type": rtype, "name": domain, "content": value, "ttl": 300},
                headers=headers, timeout=30,
            ).raise_for_status()
    log.info("Repair pushed. Remember: removing the old nameservers is a registrar action, not covered here.")


if __name__ == "__main__":
    run()
check-split-answers.js
/**
 * Find a partial nameserver cutover: query every authoritative nameserver
 * for a domain directly and flag any one whose records disagree with the rest.
 * Optionally push missing records to the new provider through the Cloudflare API.
 * Safe to run again and again. Read-only unless DRY_RUN is turned off.
 */
import { pathToFileURL } from "node:url";

const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT"];

/**
 * Pure, I/O free. nsRecords maps nameserver hostname to an object of
 * recordType -> sorted array of rdata strings. Returns an object of
 * recordType -> array of nameserver hostnames that disagree with the
 * majority answer for that type. Empty object when everything agrees.
 */
export function diffNameserverAnswers(nsRecords) {
  const disagreements = {};
  const hosts = Object.keys(nsRecords);
  if (hosts.length < 2) return disagreements;

  for (const rtype of RECORD_TYPES) {
    const answers = {};
    for (const host of hosts) {
      answers[host] = JSON.stringify((nsRecords[host][rtype] || []).slice().sort());
    }
    const counts = {};
    for (const value of Object.values(answers)) counts[value] = (counts[value] || 0) + 1;
    const distinctValues = Object.keys(counts);
    if (distinctValues.length <= 1) continue; // every nameserver agrees for this record type

    const majorityValue = distinctValues.reduce((a, b) => (counts[a] >= counts[b] ? a : b));
    const outliers = hosts.filter((host) => answers[host] !== majorityValue).sort();
    if (outliers.length) disagreements[rtype] = outliers;
  }
  return disagreements;
}

async function run() {
  const dns = await import("node:dns");
  const dnsPromises = dns.promises;

  const domain = process.env.DNS_DOMAIN;
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
  const cfToken = process.env.CLOUDFLARE_API_TOKEN;
  const cfZoneId = process.env.CLOUDFLARE_ZONE_ID;

  console.log(`Resolving nameservers for ${domain}`);
  const nsHosts = (await dnsPromises.resolveNs(domain)).sort();
  console.log(`Found ${nsHosts.length} nameserver(s): ${nsHosts.join(", ")}`);

  const nsRecords = {};
  for (const host of nsHosts) {
    let nsIp;
    try {
      nsIp = (await dnsPromises.resolve4(host))[0];
    } catch (err) {
      console.warn(`Could not resolve nameserver ${host}: ${err.message}`);
      continue;
    }
    const resolver = new dns.promises.Resolver();
    resolver.setServers([nsIp]);
    const records = {};
    for (const rtype of RECORD_TYPES) {
      try {
        if (rtype === "MX") {
          const mx = await resolver.resolveMx(domain);
          records[rtype] = mx.map((m) => `${m.priority} ${m.exchange}`).sort();
        } else if (rtype === "TXT") {
          const txt = await resolver.resolveTxt(domain);
          records[rtype] = txt.map((t) => t.join("")).sort();
        } else if (rtype === "CNAME") {
          records[rtype] = (await resolver.resolveCname(domain)).sort();
        } else if (rtype === "AAAA") {
          records[rtype] = (await resolver.resolve6(domain)).sort();
        } else {
          records[rtype] = (await resolver.resolve4(domain)).sort();
        }
      } catch {
        records[rtype] = [];
      }
    }
    nsRecords[host] = records;
  }

  const disagreements = diffNameserverAnswers(nsRecords);
  if (Object.keys(disagreements).length === 0) {
    console.log("All nameservers agree. No split detected.");
    return;
  }

  for (const [rtype, outliers] of Object.entries(disagreements)) {
    console.warn(`Record type ${rtype} disagrees on: ${outliers.join(", ")}`);
  }

  if (dryRun || !(cfToken && cfZoneId)) {
    console.log("Dry run (or missing Cloudflare credentials): not writing any records.");
    return;
  }

  // Best effort repair: copy the majority answer for each mismatched type
  // into the new provider's zone through the Cloudflare API.
  for (const rtype of Object.keys(disagreements)) {
    const majorityHost = nsHosts.find((h) => !disagreements[rtype].includes(h));
    const values = nsRecords[majorityHost][rtype] || [];
    for (const value of values) {
      console.log(`Would create/update ${rtype} record ${domain} -> ${value} at Cloudflare`);
      const res = await fetch(
        `https://api.cloudflare.com/client/v4/zones/${cfZoneId}/dns_records`,
        {
          method: "POST",
          headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
          body: JSON.stringify({ type: rtype, name: domain, content: value, ttl: 300 }),
        }
      );
      if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
    }
  }
  console.log("Repair pushed. Remember: removing the old nameservers is a registrar action, not covered here.");
}

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

Add a test

The diff rule is the part worth testing, because it decides which nameserver gets blamed for the split. Since it takes plain data in and returns a plain report out, the test needs no network and no real domain.

test_split_answers.py
from check_split_answers import diff_nameserver_answers


def test_agreement_returns_empty():
    ns_records = {
        "lena.ns.cloudflare.com": {"A": ["198.51.100.9"], "TXT": ["v=spf1 ~all"]},
        "walt.ns.cloudflare.com": {"A": ["198.51.100.9"], "TXT": ["v=spf1 ~all"]},
    }
    assert diff_nameserver_answers(ns_records) == {}


def test_flags_the_stale_nameserver():
    ns_records = {
        "ns1.oldhost.com": {"A": ["203.0.113.5"], "TXT": ["v=spf1 ~all"]},
        "lena.ns.cloudflare.com": {"A": ["198.51.100.9"], "TXT": ["v=spf1 ~all"]},
        "walt.ns.cloudflare.com": {"A": ["198.51.100.9"], "TXT": ["v=spf1 ~all"]},
    }
    result = diff_nameserver_answers(ns_records)
    assert result["A"] == ["ns1.oldhost.com"]
    assert "TXT" not in result


def test_missing_record_counts_as_a_mismatch():
    ns_records = {
        "ns1.oldhost.com": {"TXT": []},
        "lena.ns.cloudflare.com": {"TXT": ["v=spf1 include:_spf.google.com ~all"]},
        "walt.ns.cloudflare.com": {"TXT": ["v=spf1 include:_spf.google.com ~all"]},
    }
    result = diff_nameserver_answers(ns_records)
    assert result["TXT"] == ["ns1.oldhost.com"]


def test_single_nameserver_has_nothing_to_compare():
    ns_records = {"lena.ns.cloudflare.com": {"A": ["198.51.100.9"]}}
    assert diff_nameserver_answers(ns_records) == {}
check-split-answers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffNameserverAnswers } from "./check-split-answers.js";

test("agreement returns empty", () => {
  const nsRecords = {
    "lena.ns.cloudflare.com": { A: ["198.51.100.9"], TXT: ["v=spf1 ~all"] },
    "walt.ns.cloudflare.com": { A: ["198.51.100.9"], TXT: ["v=spf1 ~all"] },
  };
  assert.deepEqual(diffNameserverAnswers(nsRecords), {});
});

test("flags the stale nameserver", () => {
  const nsRecords = {
    "ns1.oldhost.com": { A: ["203.0.113.5"], TXT: ["v=spf1 ~all"] },
    "lena.ns.cloudflare.com": { A: ["198.51.100.9"], TXT: ["v=spf1 ~all"] },
    "walt.ns.cloudflare.com": { A: ["198.51.100.9"], TXT: ["v=spf1 ~all"] },
  };
  const result = diffNameserverAnswers(nsRecords);
  assert.deepEqual(result.A, ["ns1.oldhost.com"]);
  assert.equal(result.TXT, undefined);
});

test("missing record counts as a mismatch", () => {
  const nsRecords = {
    "ns1.oldhost.com": { TXT: [] },
    "lena.ns.cloudflare.com": { TXT: ["v=spf1 include:_spf.google.com ~all"] },
    "walt.ns.cloudflare.com": { TXT: ["v=spf1 include:_spf.google.com ~all"] },
  };
  const result = diffNameserverAnswers(nsRecords);
  assert.deepEqual(result.TXT, ["ns1.oldhost.com"]);
});

test("single nameserver has nothing to compare", () => {
  const nsRecords = { "lena.ns.cloudflare.com": { A: ["198.51.100.9"] } };
  assert.deepEqual(diffNameserverAnswers(nsRecords), {});
});

Case studies

Agency handoff

The migration that forgot the TXT record

An agency moved a client's domain to a new DNS host and switched the registrar nameservers the same afternoon. They copied the A record and the MX record, but missed a single TXT record used for a marketing tool's domain verification. Half the internet saw a broken integration, half saw a working one, and it took a day to realize the two zones were not identical.

Running the checker against both providers' nameservers before the registrar switch would have caught the missing TXT record in seconds, before it ever became a live split.

Old nameservers never removed

The cutover that was never finished

A store owner added a new DNS provider's nameservers at the registrar to test them, saw the new site working, and moved on without removing the old provider's nameservers. Months later the old provider's free plan expired and its zone started returning outdated records, and some visitors started seeing an old, broken site again.

The registrar delegation had quietly been split the whole time. A quick dig +short NS would have shown both providers were still listed, long before the old provider's plan lapsed.

What good looks like

After the registrar lists only the new provider's nameservers, and both of those nameservers were confirmed to hold identical records before the switch, there is nothing left to split. Every resolver, no matter which nameserver it happens to reach, gets the same answer. Run the checker once before you touch the registrar and once after, and the whole migration becomes boring, which is exactly what you want from DNS.

FAQ

Why does my domain show different records depending on who looks it up?

The registrar is still handing out both the old provider's nameservers and the new provider's nameservers, or resolvers still hold the old nameserver list cached. Any nameserver on the list is treated as equally correct, so different resolvers ask different ones and get different answers if the two zones do not match exactly.

Is it safe to just wait for propagation?

Waiting only helps once the registrar delegation lists only the new nameservers. If both old and new nameservers are still listed, the split does not resolve itself with time, since resolvers keep picking whichever one they reach. You need to remove the old nameservers at the registrar and make the zones match first.

How do I check every nameserver at once?

List the domain's nameservers with dig +short NS example.com, then query each one directly for A, AAAA, CNAME, MX, and TXT records and compare the answers. A script can automate this diff and flag any nameserver whose answers disagree with the rest.

Related field notes

Citations

On the problem:

  1. RFC 1034: Domain Names, Concepts and Facilities, the delegation consistency requirement. rfc-editor.org/rfc/rfc1034.html
  2. Akamai: What Are DNS Lame Delegations or Lame Responses? akamai.com/glossary/what-are-lame-delegations
  3. NameSilo: Changing Nameservers Safely, what breaks, what does not, and a migration checklist. namesilo.com/blog

On the solution:

  1. Cloudflare Community: How can I reduce the Nameservers TTL. community.cloudflare.com
  2. Cloudflare API: DNS Records endpoints, create, list, and update zone DNS records. api.cloudflare.com
  3. Alex Vanderbist: Comparing DNS records on two nameservers. alexvanderbist.com

Stuck on a tricky one?

If you have a DNS migration, delegation mismatch, or split answer 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 split answers?

If this saved you a confusing migration day or a client's angry email, 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