Fixing FAA Mode S Codes That Do Not Match ADS-B Addresses

On this page

Problem statement

ADS-B receivers and flight-tracking feeds identify an aircraft by its 24-bit transponder address, written as six hexadecimal characters such as a004b3. The FAA registry has that address twice: MODE S CODE HEX, and MODE S CODE, which looks like the same thing as an eight-digit number. It is the address in octal. Join ADS-B data on MODE S CODE, or convert it as if it were hexadecimal or decimal, and no aircraft matches. This page converts it correctly with the 16 October 2026 registry, shows why the mistake is silent, and builds a lookup from ADS-B address to N-number and model.

Quick answer

Read MODE S CODE as base 8 and write it as six lower-case hex digits:

reg["icao24"] = reg["MODE S CODE"].map(lambda s: f"{int(s, 8):06x}")
print(reg[["N-NUMBER", "MODE S CODE", "icao24", "MODE S CODE HEX"]].head(3).to_string(index=False))
print(f"octal converted equals the hex column: {(reg.icao24 == reg['MODE S CODE HEX'].str.lower()).mean():.2%} of {len(reg):,}")
N-NUMBER MODE S CODE icao24 MODE S CODE HEX
     100    50002263 a004b3          A004B3
   10000    50003445 a00725          A00725
   10001    50003446 a00726          A00726
octal converted equals the hex column: 100.00% of 317,604

N100's code 50002263, read as octal, is a004b3, exactly the hex column. The conversion agrees with MODE S CODE HEX for all 317,604 records.

N100's transponder code shown step by step: octal 50002263, the same 24 bits in groups of three, regrouped in fours, the hex address a004b3, and in red the misread hex value 50002263 that is eight digits long. N100's transponder code shown step by step: octal 50002263, the same 24 bits in groups of three, regrouped in fours, the hex address a004b3, and in red the misread hex value 50002263 that is eight digits long.
Octal to hex for N100: the same 24 bits, regrouped.

Octal, hex and case

1. Why the wrong reading does not fail

digits = set("".join(reg["MODE S CODE"]))
print(f"digits used in MODE S CODE: {''.join(sorted(digits))}")
wrong = reg["MODE S CODE"].map(lambda s: f"{int(s, 16):06x}")
print(f"read as hex: {wrong.str.len().value_counts().to_dict()} characters; matches the real address: {(wrong == reg.icao24).sum()}")
digits used in MODE S CODE: 01234567
read as hex: {8: 317604} characters; matches the real address: 0

The octal column only uses the digits 0 to 7, which are also valid hexadecimal and decimal digits, so int(code, 16) or int(code) never raises. Read as hex, every code becomes eight characters long, and none of them is an aircraft's address.

2. A misread code is not even an address

too_big = wrong.map(lambda h: int(h, 16) > 0xFFFFFF)
print(f"misread values above the 24-bit maximum ffffff: {too_big.mean():.1%}; misreads equal to any registered address: {wrong.isin(set(reg.icao24)).sum()}")
misread values above the 24-bit maximum ffffff: 100.0%; misreads equal to any registered address: 0

Every misread value is above ffffff, the largest 24-bit address, so none can appear in ADS-B data and none equals a registered aircraft's address. The failure shows up only as a join with no matches.

Histogram on a log scale of addresses as integers: the correctly converted codes sit just below the dashed line at ffffff, the largest 24-bit address, and every misread code sits far above it near a billion. Histogram on a log scale of addresses as integers: the correctly converted codes sit just below the dashed line at ffffff, the largest 24-bit address, and every misread code sits far above it near a billion.
Every misread code lands outside the 24-bit address space.

3. Case and leading zeros

print(reg["MODE S CODE HEX"].str.match(r"^[0-9A-F]{6}$").mean(), reg["MODE S CODE"].str.len().value_counts().to_dict())
low = reg.icao24.min(), reg.icao24.max()
print(f"US addresses run {low[0]} to {low[1]}")
1.0 {8: 317604}
US addresses run a00002 to adf7c5

The published hex column is upper case and always six characters, while most ADS-B tools (and this page's icao24) use lower case; normalise the case before joining. US addresses in the file run from a00002 to adf7c5.

N-numbers and the address block

The data used on this page

The FAA's Releasable Aircraft Database, public domain, downloaded on 16 October 2026 and saved on this site, because the FAA replaces it every day. The saved copy leaves out the owners' names and street addresses and keeps five-digit ZIP codes; every other column is as published. With it come the Census Bureau's Vintage 2025 county population estimates, 2024 county boundaries and 2024 ZCTA gazetteer.

from pathlib import Path
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
# FAA Releasable Aircraft Database, 16 October 2026: MASTER.txt without the NAME, STREET, STREET2 and OTHER NAMES
# columns and with five-digit ZIP codes (MASTER-no-names.txt), ACFTREF.txt and ENGINE.txt as published; Census
# Vintage 2025 county estimates, 2024 cartographic boundary counties and 2024 ZCTA gazetteer. Saved here unchanged
# otherwise, because the FAA replaces the file daily.
# Live: https://registry.faa.gov/database/ReleasableAircraft.zip (needs a browser User-Agent),
# https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2024_Gazetteer/2024_Gaz_zcta_national.zip
ARCHIVE = "faa-aircraft-registry-2026-10-16.zip"
if not Path(f"data/{ARCHIVE}").exists():
    Path(f"data/{ARCHIVE}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{ARCHIVE}", timeout=600).content)
Z = zipfile.ZipFile(f"data/{ARCHIVE}")


def faa(name):
    """One registry file: comma-separated, every field padded with spaces, a byte-order mark and a trailing comma."""
    d = pd.read_csv(Z.open(name), dtype=str, encoding="utf-8-sig", keep_default_na=False)
    d.columns = d.columns.str.strip()
    d = d.loc[:, [c for c in d.columns if c and not c.startswith("Unnamed")]]
    return d.apply(lambda c: c.str.strip())


reg = faa("MASTER-no-names.txt")
ref = faa("ACFTREF.txt")
TYPE_AIRCRAFT = {"1": "glider", "2": "balloon", "3": "blimp", "4": "fixed wing single engine", "5": "fixed wing multi engine",
                 "6": "rotorcraft", "7": "weight-shift", "8": "powered parachute", "9": "gyroplane", "H": "hybrid lift", "O": "other"}
REGISTRANT = {"1": "individual", "2": "partnership", "3": "corporation", "4": "co-owned", "5": "government", "7": "LLC",
              "8": "non-citizen corporation", "9": "non-citizen co-owned", "": "blank"}
Path("data/cb_2024_us_county_20m.zip").write_bytes(Z.read("cb_2024_us_county_20m.zip"))
counties = gpd.read_file("data/cb_2024_us_county_20m.zip")
STATEFP = counties.drop_duplicates("STUSPS").set_index("STUSPS").STATEFP
pop = pd.read_csv(Z.open("co-est2025-alldata.csv"), encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
pop = pop[pop.SUMLEV == 50].assign(GEOID=lambda p: p.STATE + p.COUNTY)
print(f"FAA registry, 16 October 2026: {len(reg):,} aircraft, {len(ref):,} make-and-model codes")
FAA registry, 16 October 2026: 317,604 aircraft, 94,115 make-and-model codes

Example 1: the address follows the N-number

order = reg.assign(n=reg["N-NUMBER"]).sort_values("icao24")
print(order[["n", "icao24"]].head(4).to_string(index=False))
print(order[["n", "icao24"]].tail(2).to_string(index=False))
  n icao24
 1A a00002
1AC a00005
1AD a00006
1AG a00009
    n icao24
99992 adf7c0
99997 adf7c5

The US address block is assigned in N-number order, so sorting by address sorts the registry: N1A is a00002, N1AC a00005, and N99997 adf7c5 near the top of the block. An N-number can be computed from an address, but a lookup from the registry is simpler and also gives the model.

Example 2: a lookup table for ADS-B data

lookup = reg.set_index("icao24")[["N-NUMBER", "MFR MDL CODE", "TYPE AIRCRAFT"]].join(ref.set_index("CODE")[["MFR", "MODEL"]], on="MFR MDL CODE")
print(f"addresses: {len(lookup):,}; unique: {lookup.index.is_unique}")
print(lookup.loc[["a004b3", reg.icao24.iloc[1000]]][["N-NUMBER", "MFR", "MODEL"]].to_string())
addresses: 317,604; unique: True
       N-NUMBER             MFR   MODEL
icao24                                 
a004b3      100           PIPER  J3C-65
a009af    101NZ  NORTH AMERICAN   AT-6D
Line chart of transponder address against position when the registry is sorted by address, rising steadily from N1A at a00002 to N99997 at adf7c5. Line chart of transponder address against position when the registry is sorted by address, rising steadily from N1A at a00002 to N99997 at adf7c5.
Sorted by transponder address, the registry is in N-number order.

The lookup has 317,604 addresses, all unique, so it can be used as an index for joining ADS-B positions to aircraft: a004b3 is N100, a Piper J3C-65.