Diagnostic Nameserver / Delegation

Registrar nameservers do not match the hosted zone

You moved the zone to a new DNS host, the records look right, but the host's dashboard still says the zone is pending or inactive. The reason is almost always the same: the registrar is still telling the world to use the old nameservers. Here is why the two sides can disagree, how to check both of them, and how to make the registrar match the zone.

Python and Node.js RDAP + dnspython Diagnostic only
A security dashboard
Photo by Zulfugar Karimov on Unsplash
The short answer

Every domain has two separate nameserver lists: the one the registrar publishes to the registry, and the one the zone itself actually answers with. Moving a zone to a new DNS host only updates the second list. If nobody also updates the nameserver list at the registrar, resolvers keep following the registrar's old delegation and never reach the new zone, and the DNS host's activation check keeps failing. Fix it by copying the exact nameserver hostnames from the new DNS host into the registrar's nameserver settings. A script can detect this mismatch by comparing RDAP against a live NS query, but the fix itself has to happen at the registrar.

The problem in plain words

Think of a domain as having two address books. One is kept by the registry, through your registrar, and it says "for example.com, ask these nameservers." The other is the zone file itself, sitting on whichever DNS host you actually use, and it has its own NS records that say the same thing from the inside.

Normally both books agree. But nothing forces them to. When you move a zone, say from an old host to Cloudflare or Route 53, you update the zone's own NS records (or the new host creates them for you), but the registrar's book still has the old entries until you go change them by hand. Resolvers out on the internet only ever read the registrar's book first, so they keep going to the old host no matter how correct the new zone is. The DNS host also checks this and refuses to call the zone active until the registrar catches up.

Resolver looks up example.com Registry reads registrar's NS list still says old host Old nameservers stale or gone New zone correct, but unreachable Zone stays Pending
The registry only ever reads the registrar's nameserver list. If that list still names the old host, the new zone is never reached, no matter how correct it is.

Why it happens

RFC 1034 describes nameservers and the parent/child cut between a domain and the zone above it, and expects both sides to agree, but nothing in the protocol enforces that automatically. A few common ways they drift apart:

Whatever the cause, the symptom looks the same from the DNS host's side: it cannot see itself listed as the domain's delegated nameservers, so it will not call the zone active.

The key insight

The zone's own NS records and the registrar's nameserver list are two different things stored in two different places. Editing one never edits the other. A mismatch is not a bug in either system, it is just a manual step that got skipped, and the fix always happens at the registrar, not inside the zone.

The fix, as a flow

Get the exact nameserver hostnames from wherever the zone actually lives now, then go to the registrar and replace the old list with those exact values. Nothing on the zone side needs to change. If DNSSEC is on, handle the DS record first so you do not trade a delegation problem for a validation failure.

Get NS from the new DNS host Open registrar's nameserver settings Replace old list paste exact hostnames Confirm unlocked no transfer lock Save, wait for registry to publish
Nothing changes inside the zone. The registrar's nameserver list is copied to match the zone's real nameservers, and then the registry needs time to publish it.

How to fix it

1

Get the exact nameserver hostnames from the DNS host

Open the zone's overview page at the DNS host and copy the nameservers it assigned. Cloudflare gives two, something like bob.ns.cloudflare.com and kate.ns.cloudflare.com. Route 53 gives four, in the Hosted zone's NS record, something like the values below. Copy them exactly, do not retype them by hand.

DNS record
example.com NS (as shown by the DNS host)
; Cloudflare style, two nameservers
example.com.  NS  bob.ns.cloudflare.com.
example.com.  NS  kate.ns.cloudflare.com.

; Route 53 style, four nameservers
example.com.  NS  ns-123.awsdns-45.com.
example.com.  NS  ns-678.awsdns-90.net.
example.com.  NS  ns-1111.awsdns-22.org.
example.com.  NS  ns-2222.awsdns-67.co.uk.
2

Replace the nameserver list at the registrar

Log into the registrar (GoDaddy, Namecheap, the Route 53 registrar console, or wherever the domain is registered) and open the domain's nameserver settings. Delete every old entry first, then paste in the new hostnames from step 1, one per field, copy and paste rather than typing.

3

Handle DNSSEC before you save, if it is on

If the domain has DNSSEC enabled, an old DS record at the registrar can point at signing keys the new DNS host does not serve. That combination causes SERVFAIL even after the nameserver list is fixed. Remove or update the DS record at the registrar to match what the new DNS host publishes, or turn DNSSEC off temporarily while you sort out the migration, then re-enable it once the new host's keys are in place.

4

Confirm the domain is unlocked

Registrar Lock, sometimes called Transfer Lock, can silently stop a nameserver change from saving even when the form appears to accept it. Check the domain's status and turn the lock off before you save the nameserver change, then you can turn it back on afterward.

5

Save and wait for the registry to publish it

Once saved, the registry needs to pick up the change and publish the new delegation. This is usually minutes to a few hours, but can take up to 48 hours in the worst case, since it depends on the TTL policy of the registry and how quickly caches around the world expire.

How to check it worked

Compare the registry's view against the zone's live answer. They should list the exact same nameservers.

Terminal
verify.sh
# 1. What the registry publishes for the domain (the registrar's delegation)
curl -s https://rdap.org/domain/example.com | jq '.nameservers[].ldhName'

# Alternative, classic whois
whois example.com | grep -i "Name Server"

# 2. What the zone itself actually answers with
dig NS example.com +short @8.8.8.8

# 3. Ask one of the new nameservers directly, it should answer authoritatively
dig @ns-123.awsdns-45.com example.com NS +norecurse

# 4. Trace the full delegation path from the root down
dig +trace example.com

A good result looks like this: the RDAP list and the dig NS list contain the exact same hostnames (order does not matter). The direct query in step 3 comes back with the aa flag set and the full NS list, not REFUSED, SERVFAIL, or a timeout. The trace in step 4 shows the TLD servers handing off straight to the new nameservers, with no old hostname appearing anywhere in the chain. In the DNS host's dashboard, the zone's status should flip from Pending or Inactive to Active.

The full code

This problem is a diagnostic, not a repair. A DNS provider's API can only manage records inside a zone that is already delegated to it, it has no way to change what the registrar publishes to the registry. So the script below detects the mismatch and reports it clearly, and leaves the actual fix as the registrar steps above.

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

check_ns_mismatch.py
"""Detect a mismatch between the registrar's delegated nameservers and the
nameservers the zone actually answers with. Diagnostic only: the registrar
side cannot be fixed through the Cloudflare DNS API, so this script never
writes anything, it only reports what it finds.
"""
import os
import logging

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

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", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def normalize_ns(hostname):
    return hostname.strip().lower().rstrip(".")


def ns_sets_match(registrar_ns, zone_ns):
    """Pure function, no I/O. Normalize each hostname (lowercase, strip the
    trailing dot) and compare the two lists as sets."""
    return {normalize_ns(h) for h in registrar_ns} == {normalize_ns(h) for h in zone_ns}


def get_registrar_ns(domain):
    """RDAP is the registry-facing view: what the registrar has delegated."""
    import requests

    r = requests.get(f"https://rdap.org/domain/{domain}", timeout=15)
    r.raise_for_status()
    data = r.json()
    return [ns["ldhName"] for ns in data.get("nameservers", []) if ns.get("ldhName")]


def get_zone_ns(domain):
    """dnspython gives the live, authoritative-side view of the zone's NS set."""
    import dns.resolver

    answer = dns.resolver.resolve(domain, "NS")
    return [str(rdata.target) for rdata in answer]


def run():
    log.info("Checking nameserver delegation for %s (DRY_RUN=%s)", DNS_DOMAIN, DRY_RUN)

    registrar_ns = get_registrar_ns(DNS_DOMAIN)
    zone_ns = get_zone_ns(DNS_DOMAIN)

    log.info("Registrar (RDAP) nameservers: %s", sorted(registrar_ns))
    log.info("Zone (live NS query) nameservers: %s", sorted(zone_ns))

    if ns_sets_match(registrar_ns, zone_ns):
        log.info("OK: registrar delegation matches the live zone. Nothing to do.")
        return

    log.warning(
        "MISMATCH: the registrar delegates to %s but the zone answers with %s. "
        "This is a registrar-side fix, not something the Cloudflare DNS API can change. "
        "Update the nameserver list at the registrar to match the zone's NS set.",
        sorted(registrar_ns), sorted(zone_ns),
    )

    # Note for future readers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are
    # accepted for consistency with the other fixes in this repo, and would be
    # used to manage records inside a zone already delegated to Cloudflare via
    # https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records, but
    # that endpoint has no way to touch the registrar's delegation itself, so
    # this script never calls it.
    if not DRY_RUN:
        log.info("DRY_RUN is false, but this check never writes. Fix the registrar by hand.")


if __name__ == "__main__":
    run()
check-ns-mismatch.js
/**
 * Detect a mismatch between the registrar's delegated nameservers and the
 * nameservers the zone actually answers with. Diagnostic only: the registrar
 * side cannot be fixed through the Cloudflare DNS API, so this script never
 * writes anything, it only reports what it finds.
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function normalizeNs(hostname) {
  return hostname.trim().toLowerCase().replace(/\.$/, "");
}

export function nsSetsMatch(registrarNs, zoneNs) {
  // Pure function, no I/O. Normalize each hostname and compare as sets.
  const a = new Set(registrarNs.map(normalizeNs));
  const b = new Set(zoneNs.map(normalizeNs));
  if (a.size !== b.size) return false;
  for (const host of a) if (!b.has(host)) return false;
  return true;
}

async function getRegistrarNs(domain) {
  // RDAP is the registry-facing view: what the registrar has delegated.
  const res = await fetch(`https://rdap.org/domain/${domain}`);
  if (!res.ok) throw new Error(`RDAP lookup failed: ${res.status}`);
  const data = await res.json();
  return (data.nameservers || []).map((ns) => ns.ldhName).filter(Boolean);
}

async function getZoneNs(domain) {
  // The built-in dns module gives the live, authoritative-side view.
  const dns = await import("node:dns/promises");
  return dns.resolveNs(domain);
}

export async function run() {
  console.log(`Checking nameserver delegation for ${DNS_DOMAIN} (DRY_RUN=${DRY_RUN})`);

  const registrarNs = await getRegistrarNs(DNS_DOMAIN);
  const zoneNs = await getZoneNs(DNS_DOMAIN);

  console.log("Registrar (RDAP) nameservers:", [...registrarNs].sort());
  console.log("Zone (live NS query) nameservers:", [...zoneNs].sort());

  if (nsSetsMatch(registrarNs, zoneNs)) {
    console.log("OK: registrar delegation matches the live zone. Nothing to do.");
    return;
  }

  console.warn(
    `MISMATCH: the registrar delegates to ${JSON.stringify([...registrarNs].sort())} ` +
    `but the zone answers with ${JSON.stringify([...zoneNs].sort())}. ` +
    "This is a registrar-side fix, not something the Cloudflare DNS API can change. " +
    "Update the nameserver list at the registrar to match the zone's NS set."
  );

  // Note for future readers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are
  // accepted for consistency with the other fixes in this repo, and would be
  // used to manage records inside a zone already delegated to Cloudflare via
  // https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records, but that
  // endpoint has no way to touch the registrar's delegation itself, so this
  // script never calls it.
  if (!DRY_RUN) {
    console.log("DRY_RUN is false, but this check never writes. Fix the registrar by hand.");
  }
}

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

Add a test

The part worth testing is the comparison itself, since it decides whether the script reports a mismatch or not. It takes plain lists of hostnames in and returns true or false, so the test needs no network and no real domain.

test_registrar_ns_match.py
from check_ns_mismatch import ns_sets_match


def test_matches_when_identical():
    assert ns_sets_match(["ns1.example.com"], ["ns1.example.com"]) is True


def test_matches_ignoring_case():
    assert ns_sets_match(["NS1.EXAMPLE.COM"], ["ns1.example.com"]) is True


def test_matches_ignoring_trailing_dot():
    assert ns_sets_match(["ns1.example.com."], ["ns1.example.com"]) is True


def test_matches_ignoring_order():
    a = ["bob.ns.cloudflare.com", "kate.ns.cloudflare.com"]
    b = ["kate.ns.cloudflare.com", "bob.ns.cloudflare.com"]
    assert ns_sets_match(a, b) is True


def test_mismatch_on_old_vs_new_host():
    old = ["ns1.oldhost.com", "ns2.oldhost.com"]
    new = ["bob.ns.cloudflare.com", "kate.ns.cloudflare.com"]
    assert ns_sets_match(old, new) is False


def test_mismatch_when_one_list_has_an_extra_server():
    a = ["ns1.example.com", "ns2.example.com"]
    b = ["ns1.example.com", "ns2.example.com", "ns3.example.com"]
    assert ns_sets_match(a, b) is False


def test_mismatch_when_one_list_is_missing_a_server():
    a = ["ns1.example.com", "ns2.example.com"]
    b = ["ns1.example.com"]
    assert ns_sets_match(a, b) is False


def test_both_empty_counts_as_matching():
    assert ns_sets_match([], []) is True


def test_one_empty_one_not_is_a_mismatch():
    assert ns_sets_match([], ["ns1.example.com"]) is False
registrar-ns-match.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { nsSetsMatch } from "./check-ns-mismatch.js";

test("matches when identical", () => {
  assert.equal(nsSetsMatch(["ns1.example.com"], ["ns1.example.com"]), true);
});

test("matches ignoring case", () => {
  assert.equal(nsSetsMatch(["NS1.EXAMPLE.COM"], ["ns1.example.com"]), true);
});

test("matches ignoring trailing dot", () => {
  assert.equal(nsSetsMatch(["ns1.example.com."], ["ns1.example.com"]), true);
});

test("matches ignoring order", () => {
  const a = ["bob.ns.cloudflare.com", "kate.ns.cloudflare.com"];
  const b = ["kate.ns.cloudflare.com", "bob.ns.cloudflare.com"];
  assert.equal(nsSetsMatch(a, b), true);
});

test("mismatch on old vs new host", () => {
  const oldNs = ["ns1.oldhost.com", "ns2.oldhost.com"];
  const newNs = ["bob.ns.cloudflare.com", "kate.ns.cloudflare.com"];
  assert.equal(nsSetsMatch(oldNs, newNs), false);
});

test("mismatch when one list has an extra server", () => {
  const a = ["ns1.example.com", "ns2.example.com"];
  const b = ["ns1.example.com", "ns2.example.com", "ns3.example.com"];
  assert.equal(nsSetsMatch(a, b), false);
});

test("mismatch when one list is missing a server", () => {
  const a = ["ns1.example.com", "ns2.example.com"];
  const b = ["ns1.example.com"];
  assert.equal(nsSetsMatch(a, b), false);
});

test("both empty counts as matching", () => {
  assert.equal(nsSetsMatch([], []), true);
});

test("one empty one not is a mismatch", () => {
  assert.equal(nsSetsMatch([], ["ns1.example.com"]), false);
});

Case studies

Migration

The Cloudflare zone that never left Pending

A team added their domain to Cloudflare, copied every record over from the old host, and waited. A day later the dashboard still said Pending. Nothing in the zone was wrong. Nobody had gone back to the registrar to change the nameserver list, so the registry was still pointing at the old host.

Once they logged into the registrar, replaced the two old nameserver entries with the two Cloudflare ones, and saved, the zone went Active within the hour.

DNSSEC

Fixed the nameservers, broke resolution anyway

A domain owner updated the nameserver list at the registrar to point at the new host, confirmed the RDAP and live NS query matched, and thought they were done. Visitors started getting SERVFAIL instead of the old stale answers.

DNSSEC was enabled, and the registrar still had a DS record referencing the old host's signing key. Removing the stale DS record and letting the new host publish its own cleared the SERVFAIL immediately.

What good looks like

Once the registrar's nameserver list matches the zone's own NS records exactly, resolvers everywhere get sent to the right place on their very next lookup, and the DNS host's dashboard shows the zone as Active. Check both sides any time you move a zone to a new host, right after you make the change and again a day later, since the registry can take a while to catch up.

FAQ

Why does my DNS host say the zone is still pending?

Your registrar is still telling the world to use the old nameservers. The DNS host checks that the registrar's delegation matches its own nameservers before it marks the zone active. Until you update the nameserver list at the registrar, the zone stays pending even though the records inside it are correct.

Why do some visitors still see the old site after I moved DNS?

Resolvers only find your zone by first asking the registry which nameservers to use. If the registrar record still lists the old nameservers, every resolver that asks gets sent to the old host, no matter how correct the new zone is.

Is this something I can fix with an API call?

No. A DNS provider's API, like Cloudflare's, only manages records inside a zone that is already delegated to it. The nameserver list itself is stored at the registrar, so changing it means logging into the registrar and saving the new values there.

Related field notes

Citations

On the problem:

  1. RFC 1034, Domain Names: Concepts and Facilities, section 4.2.2, on NS and glue consistency across the parent/child cut. rfc-annotations.research.icann.org/rfc1034.html
  2. CAIDA: When Parents and Children Disagree, Diving into DNS Delegation Inconsistency. caida.org/catalog/papers/2020_when_parents_children_disagree
  3. Cloudflare Community: Nameserver mismatch on a Cloudflare-registered domain, zone stuck in pending. community.cloudflare.com

On the solution:

  1. Cloudflare docs: Update nameservers. developers.cloudflare.com/dns/nameservers/update-nameservers
  2. AWS Route 53 Developer Guide: Getting the name servers for a public hosted zone. docs.aws.amazon.com/Route53
  3. AWS re:Post: Successfully update your name servers at the Route 53 registrar. repost.aws/knowledge-center

Stuck on a tricky one?

If you have a DNS, delegation, or email authentication 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 pending zone?

If this saved you a stuck migration 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

Back to all DNS and Domains field notes