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.
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.
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
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.
Internal links
- Read the FAA registry β both Mode S columns
- Join records to make and model β the ACFTREF half of the lookup
- Follow an aircraft through its day β tail numbers in airline data