FAA Registration Status Codes Explained: Valid, Pending and Expired
On this page
Problem statement
Not every row in the FAA registry is a registered aircraft. The STATUS CODE column distinguishes valid registrations from pending ones, aircraft whose sale has been reported, N-numbers assigned to an aircraft that was never registered, and registrations on their way to cancellation. The FAA's file documentation lists the codes, from letters such as V and R to the numbers 1 to 29, and an analysis that counts every row counts some aircraft that are not on the register. This page decodes the codes in the 16 October 2026 file, checks the seven-year expiry, and shows which figures change when the filter is applied.
Quick answer
Count each code and attach its meaning from the FAA's documentation:
STATUS = {"V": "valid", "R": "registration pending", "M": "valid, manufacturer's dealer certificate", "T": "valid, trainee",
"N": "non-citizen corporation, flight hours not reported", "7": "sale reported", "2": "N-number assigned, not yet registered",
"10": "assigned, not registered, pending cancellation", "17": "sale reported, pending cancellation",
"19": "registration pending, pending cancellation", "18": "sale reported, canceled", "3": "assigned (non type certificated), not yet registered"}
st = reg["STATUS CODE"].value_counts()
print(pd.DataFrame({"records": st.head(10), "meaning": st.head(10).index.map(STATUS)}).to_string())
print(f"valid (V, M, T): {reg['STATUS CODE'].isin(['V', 'M', 'T']).mean():.1%}")
records meaning
STATUS CODE
V 310844 valid
R 2155 registration pending
M 1330 valid, manufacturer's dealer certificate
7 870 sale reported
2 854 N-number assigned, not yet registered
N 282 non-citizen corporation, flight hours not reported
10 250 assigned, not registered, pending cancellation
17 209 sale reported, pending cancellation
19 143 registration pending, pending cancellation
18 131 sale reported, canceled
valid (V, M, T): 98.3%
310,844 records are valid (V), 2,155 pending (R) and 1,330 valid under a manufacturer's dealer certificate (M). With the trainee code T, valid records are 98.3% of the file. The rest are sale reports, assigned but unregistered N-numbers and cancellations in progress.
What the codes change
1. Every status, grouped
group = reg["STATUS CODE"].map(lambda c: "valid" if c in ("V", "M", "T") else "pending" if c in ("R", "19") else
"sale or transfer" if c in ("7", "17", "18") else "assigned, never registered" if c in ("2", "3", "4", "10", "11", "12") else "other")
print(group.value_counts().to_string())
print(f"distinct codes: {reg['STATUS CODE'].nunique()}")
STATUS CODE
valid 312174
pending 2298
assigned, never registered 1388
sale or transfer 1210
other 534
distinct codes: 21
Grouped, 312,174 records are valid in one form or another, 2,298 pending, 1,388 assigned but never registered and 1,210 sold or being transferred. The file uses 21 distinct codes.
2. Expiry dates: seven years
v = reg[reg["STATUS CODE"] == "V"].copy()
v["issued"] = pd.to_datetime(v["CERT ISSUE DATE"], format="%Y%m%d", errors="coerce")
v["expires"] = pd.to_datetime(v["EXPIRATION DATE"], format="%Y%m%d", errors="coerce")
recent = v[v.issued >= "2024-01-01"]
gap = (recent.expires - recent.issued).dt.days / 365.25
print(f"certificates issued since 2024: {len(recent):,}; years to expiry, median {gap.median():.2f}, share 6.9-7.1: {gap.between(6.9, 7.1).mean():.1%}")
print(v.expires.dt.year.value_counts().sort_index().loc[2026:2034].to_string())
certificates issued since 2024: 86,364; years to expiry, median 7.04, share 6.9-7.1: 100.0%
expires
2026.0 3
2027.0 43742
2028.0 65002
2029.0 64763
2030.0 48871
2031.0 28244
2032.0 31416
2033.0 26717
Every certificate issued since 2024, 86,364 of them, expires about seven years after issue (median 7.04 years): the FAA moved from three-year to seven-year registrations in 2023. Expiry dates in the file therefore run from 2027 to 2033, and very few valid registrations expire in 2026.
3. Valid but already past expiry
today = pd.Timestamp("2026-10-16")
print(f"status V with an expiry date before 16 October 2026: {(v.expires < today).sum():,}")
print(v[v.expires < today][["N-NUMBER", "EXPIRATION DATE", "LAST ACTION DATE"]].head(4).to_string(index=False))
status V with an expiry date before 16 October 2026: 4
N-NUMBER EXPIRATION DATE LAST ACTION DATE
303RJ 20260531 20241212
4980S 20250520 20250520
664NK 20250430 20250410
757MK 20260630 20250804
Four status-V records have an expiry date before the day the file was made. Their last action dates are recent, so they are most likely renewals in progress; a status code is not always in step with the dates beside it.
How the counts move with the filter
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: aircraft by state, with and without the filter
us = reg[reg.COUNTRY == "US"]
a, b = us.STATE.value_counts(), us[us["STATUS CODE"] == "V"].STATE.value_counts()
d = pd.DataFrame({"all records": a, "status V": b}).dropna()
d["share not valid"] = 1 - d["status V"] / d["all records"]
print(d[d["all records"] >= 2000].sort_values("share not valid", ascending=False).head(5).round(3).to_string())
all records status V share not valid
STATE
MN 6903 6430 0.069
KS 5728 5410 0.056
GA 8786 8423 0.041
IA 4494 4389 0.023
SD 2274 2225 0.022
Minnesota has the largest share of records that are not valid, 6.9%, then Kansas at 5.6%. Any state count made without the filter is off by up to that much, and the error varies by state.
Example 2: which aircraft are pending
pend = reg[reg["STATUS CODE"] == "R"]
pend_issued = pd.to_datetime(pend["LAST ACTION DATE"], format="%Y%m%d", errors="coerce")
print(f"pending: {len(pend):,}; last action in 2026: {(pend_issued.dt.year == 2026).mean():.1%}; median last action {pend_issued.median():%Y-%m-%d}")
print(pend["TYPE REGISTRANT"].map(REGISTRANT).value_counts().head(3).to_string())
pending: 2,155; last action in 2026: 91.5%; median last action 2026-07-16
TYPE REGISTRANT
individual 2155
The 2,155 pending registrations are recent: 91.5% had their last action in 2026, the median on 16 July. Every one of them is listed with an individual as the registrant type.
Internal links
- Read the FAA registry β where the status column sits
- Registered aircraft per resident β a map that filters on status V
- Age of the fleet β another figure that changes with the filter
- Registration addresses β the owner-type column
FAQ
What does status code V mean in the FAA registry?
A valid registration. In the 16 October 2026 file 310,844 of 317,604 records are V; the rest are pending, dealer, sale-reported, assigned-but-unregistered and cancellation states.