Validating FCC ASR numbers in Python

An FCC Antenna Structure Registration (ASR) number is the anchor key that ties a tower’s structural filing under 47 CFR Part 17 to every downstream compliance record — the lease, the zoning file, the audit trail. If that anchor is wrong, everything hung off it is wrong too, but a bad ASR number rarely looks bad: a transcribed 1O90123 still reads as seven characters at a glance, and a truncated 129012 still passes a casual eyeball check if nobody counts carefully. This page is the small utility layer underneath the Regulatory Source Crosswalk: the boring, load-bearing function that decides whether a raw ASR string is safe to write into a compliance record before it becomes a join key nothing ever questions again.

Prerequisites & Context

You need Python 3.10 or newer and nothing else — this task runs entirely on the standard library, using re for pattern matching, hashlib for the audit seal, and logging for structured output. There is no pip install step, and no third-party ASR-lookup client to configure.

Before writing a single line, it helps to be honest about what this validation can’t do. Unlike a credit card number or an IBAN, an FCC ASR number carries no public check digit — there is no arithmetic formula that turns 1290123 into a self-verifying value the way a Luhn checksum does. The FCC assigns ASR numbers sequentially through its own internal registration system when a structure is filed under 47 CFR Part 17; the number itself is just an opaque 7-digit identifier. That means format validation alone can never prove an ASR number is correct — only that it is plausible. A syntactically perfect 7-digit string that nobody ever filed is still wrong, and no regular expression will catch it. The only way to close that gap is a second check against a known set of registered numbers: a registry lookup, not a checksum. This page treats both halves as mandatory and keeps them clearly separated, so a caller always knows which kind of failure it’s looking at.

This is a companion utility to two other reference pages worth knowing about before you use it in production. The canonical shape of the fcc_asr_number field — type, length, and where it sits in a compliance record — is defined once in the Canonical Field Dictionary, so this validator’s output should always match that contract rather than inventing its own. And if your pipeline is choosing which systems get to read or write that field once it’s validated, that access-control question is covered separately in Mapping NIST SP 800-53 controls to tower data access — a distinct concern from whether the value itself is trustworthy. Both pages sit alongside this one under the wider Telecom Compliance Data Reference, which exists precisely to keep field-level, access-level, and source-level rules from silently drifting apart.

Step-by-Step Implementation

Step 1 — Strip formatting and whitespace. Raw ASR numbers arrive from spreadsheets, hand-typed forms, and copy-pasted FCC lookup URLs, so they carry hyphens, embedded spaces, and leading or trailing padding that have nothing to do with the registration itself.

python
def strip_asr(raw: str) -> str:
    return re.sub(r"[\s\-\.]", "", raw.strip())

Step 2 — Validate the ASR is a 7-digit registration, leading zeros allowed. The FCC’s ASR numbering space is exactly seven digits wide, and a structure registered early enough to have a low-numbered ASR keeps its leading zero — 0998214 is a real, valid shape, not a formatting artifact to be trimmed away.

python
if len(cleaned) > 7 or not cleaned.isdigit():
    raise ASRValidationError(f"not a plausible ASR shape: {raw!r}")

Step 3 — Reject common transcription errors before they reach the length check. The two failures worth catching explicitly, rather than letting them fall through as a generic mismatch, are the letter O substituted for the digit 0 — visually near-identical in most fonts and in handwritten inspection notes — and a string that is simply the wrong length once formatting is stripped. Both get their own branch and their own log line, because “wrong length” and “looks like a digit but isn’t” point an operator toward two completely different upstream fixes.

python
if any(ch in "Oo" for ch in cleaned):
    raise ASRValidationError(f"letter O used in place of digit 0: {raw!r}")

Step 4 — Canonicalize to a zero-padded 7-character string. Once the value has passed the shape check, pad it to the full width with str.zfill(7) so 998214 and 0998214 collapse to the same canonical key everywhere downstream — a JSON record, a database column, an audit hash input all see one consistent representation.

python
canonical = cleaned.zfill(7)

Step 5 — Seal the validation result with hashlib.sha256. Format validity is necessary but not sufficient, so the canonical value is checked against a known registry set next; only a value that clears both checks gets sealed. The digest is taken over the canonical ASR string plus a fixed status tag, giving a tamper-evident record of what was validated and that it passed, independent of whatever database row it later lands in.

python
def seal_validation(canonical: str) -> str:
    payload = f"ASR|{canonical}|VALID"
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()
From a raw ASR string to a validated, canonical registration number A raw ASR string flows through five stages left to right: strip whitespace and formatting, a format check for seven digits with no letter O, a registry check against a known set of registered ASR numbers, and a final canonical zero-padded string. The format check and registry check each have a failure branch, shown dashed, that converges on a raised ASRValidationError below the main pipeline. fail Raw ASR string Strip whitespace & formatting re.sub Format check 7 digits no letter O Registry check known ASR set existence only Canonical zfill(7) Raise ASRValidationError

Figure: format checks and the registry lookup each guard a separate failure mode, and both converge on the same raised error.

Complete Runnable Example

The block below is self-contained, uses only re, hashlib, and logging from the standard library, and runs unmodified on Python 3.10+. The KNOWN_ASR_NUMBERS set stands in for a synced local mirror of the FCC’s registered structures — in production this would be a periodically refreshed table, not a hardcoded literal, but the validation logic against it is identical either way.

python
import re
import hashlib
import logging

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

ASR_PATTERN = re.compile(r"^[0-9]{7}$")

# Mock snapshot of registered structures (production: a synced FCC ASR mirror)
KNOWN_ASR_NUMBERS = {"1290123", "1204551", "1041877", "0998214"}


class ASRValidationError(Exception):
    """Raised when a raw ASR string fails format or registry validation."""


def strip_asr(raw: str) -> str:
    return re.sub(r"[\s\-\.]", "", raw.strip())


def validate_asr(raw: str) -> str:
    cleaned = strip_asr(raw)
    if any(ch in "Oo" for ch in cleaned):
        log.warning("rejected %r: letter O used in place of digit 0", raw)
        raise ASRValidationError(f"letter O in place of digit 0: {raw!r}")
    if not cleaned.isdigit() or len(cleaned) > 7:
        log.warning("rejected %r: not a plausible 7-digit ASR shape", raw)
        raise ASRValidationError(f"not a plausible ASR shape: {raw!r}")
    canonical = cleaned.zfill(7)
    if not ASR_PATTERN.match(canonical):
        log.warning("rejected %r: failed final format check", raw)
        raise ASRValidationError(f"failed format check: {raw!r}")
    if canonical not in KNOWN_ASR_NUMBERS:
        log.warning("rejected %r: canonical %s not in registry snapshot", raw, canonical)
        raise ASRValidationError(f"ASR {canonical} not found in registry: {raw!r}")
    return canonical


def seal_validation(canonical: str) -> str:
    payload = f"ASR|{canonical}|VALID"
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


if __name__ == "__main__":
    for sample in ["1290123", "129-0123 ", "1O90123", "998214", "5551234"]:
        try:
            canonical = validate_asr(sample)
            digest = seal_validation(canonical)
            log.info("ASR %s valid, audit=%s", canonical, digest[:16])
        except ASRValidationError as err:
            log.error("validation failed: %s", err)

Verification & Expected Output

Save the block as asr_validate.py and run python3 asr_validate.py. The five samples cover the full decision tree: a clean value, a formatted-but-valid value, a letter-O typo, a leading-zero-omitted value, and a syntactically fine but unregistered value. Expected output is:

text
INFO ASR 1290123 valid, audit=... (16-char digest)
INFO ASR 1290123 valid, audit=... (same 16-char digest as above)
ERROR validation failed: letter O in place of digit 0: '1O90123'
INFO ASR 0998214 valid, audit=... (16-char digest)
ERROR validation failed: ASR 5551234 not found in registry: '5551234'

Notice that "1290123" and "129-0123 " produce identical canonical values and therefore identical audit digests — that determinism is what makes the seal useful downstream, since two differently formatted mentions of the same registration collapse to one auditable identity. Notice too that "998214" canonicalizes to "0998214" and passes, because leading-zero omission is a formatting choice, not a validation failure. The last case is the one worth sitting with: 5551234 is a perfectly well-formed 7-digit string that clears every syntactic check and still fails, because it simply isn’t in the registry snapshot. That is the entire reason format checking and registry checking are kept as two separate, separately logged branches — a caller reading the log can tell instantly whether a rejected ASR was garbled in transcription or was never filed at all.

Gotchas & Edge Cases

  • Spreadsheets silently eat leading zeros. A CSV column typed as a number in Excel or Google Sheets turns 0998214 into 998214 the moment someone opens and resaves the file, and no error is raised anywhere in that chain. The zfill(7) canonicalization step recovers this, but only if the value still has some digits left to pad — if the sheet also drops a real leading zero pair or a technician retypes the number from memory, padding just produces a plausible-looking wrong ASR that then depends entirely on the registry check to catch.
  • No check digit means no free lunch on typos. Because ASR numbers carry no arithmetic self-verification, a single transposed digit — 1290123 typed as 1290132 — produces a value that is exactly as syntactically valid as the original. If both happen to be registered structures, format and registry checks both pass silently, and the record ends up anchored to the wrong tower. The only real defense is cross-checking the resolved ASR against a second independent field on the same record — a site coordinate, an owner ID — at the point of ingestion, not inside this validator.
  • A stale registry snapshot rejects real, newly filed structures. Unlike the format check, which never changes, the registry set is a point-in-time mirror. A tower registered with the FCC last week will fail the registry check against last month’s snapshot even though the ASR number is entirely legitimate — that failure means “refresh your mirror,” not “this ASR is fraudulent,” and the two should never be logged or alerted on identically.

FAQ

Does the FCC's ASR numbering scheme include a check digit?

No. Unlike a credit card number or an IBAN, an FCC Antenna Structure Registration number has no embedded checksum digit — it is an opaque sequential identifier assigned when a structure is filed under 47 CFR Part 17. That means format validation can only confirm a number is plausibly shaped, never that it is correct; catching a genuinely wrong-but-well-formed ASR number requires checking it against a known registry of filed structures, not a formula.

Why check against a local registry set instead of a live lookup on every record?

A local, periodically refreshed snapshot keeps validation fast and available even when an external system is slow or unreachable, and it keeps the check deterministic for auditing — the same input produces the same result regardless of network conditions at validation time. The trade-off is that the snapshot can go stale, which is why a registry-check failure should be logged and treated differently from a format-check failure: one means “refresh the mirror,” the other means “fix the input.”

What happens to an ASR number that is the right shape but isn't in the registry?

It is rejected with its own category of ASRValidationError, distinct from a format failure. This is the case a checksum-based validator could never catch, since 5551234 in the example above is a completely well-formed 7-digit string — it simply was never filed, or hasn’t yet synced into the local registry snapshot. Treating this as a hard failure, rather than letting it slip through on format alone, is the entire point of pairing the two checks.

Related pages