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.
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.
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:
- The registrar's nameserver list was updated to add the new provider's nameservers but the old provider's nameservers were never removed, so both sets are delegated at once.
- The registrar list is clean, but a resolver somewhere still has the old nameserver list cached from before, and that cache has not expired yet.
- The new zone was set up in a hurry and is missing a record that only exists on the old zone, such as a TXT record used for SPF or DKIM or a domain ownership check.
- The old provider's TTL values were left high, so even after the registrar switch, some resolvers keep using stale cached answers for a long time.
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.
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.
How to fix it
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.
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.
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.
# 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
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.
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
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.
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)
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.
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.
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.
"""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()
/**
* 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.
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) == {}
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
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.
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.
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:
- RFC 1034: Domain Names, Concepts and Facilities, the delegation consistency requirement. rfc-editor.org/rfc/rfc1034.html
- Akamai: What Are DNS Lame Delegations or Lame Responses? akamai.com/glossary/what-are-lame-delegations
- NameSilo: Changing Nameservers Safely, what breaks, what does not, and a migration checklist. namesilo.com/blog
On the solution:
- Cloudflare Community: How can I reduce the Nameservers TTL. community.cloudflare.com
- Cloudflare API: DNS Records endpoints, create, list, and update zone DNS records. api.cloudflare.com
- 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.
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