A busy hub can have well over a hundred active NOTAMs, and most of them do not matter to most users. Somewhere in there is the closed taxiway that affects the departure and the unserviceable approach light that changes the minima, sitting alongside forty notices about cranes twelve miles away and a monthly checklist notice that exists purely for administrative bookkeeping.
Reading NOTAMs is a solved problem for humans who've been trained to do it. Getting a program to decide which ones to show is not, and that's what this post is about: classification, filtering, and the parsing edge cases that will break a naive implementation.
If you need the human-readable version first — what the codes mean and how to read one by eye — NOTAM search, find, filter, and decode covers that ground. This is the engineering underneath.
What comes back
A request returns active notices for an airport, each with the raw ICAO text plus the individually parsed items:
import requests
BASE = "https://skylink-api.p.rapidapi.com"
H = {"X-RapidAPI-Key": KEY, "X-RapidAPI-Host": "skylink-api.p.rapidapi.com"}
r = requests.get(f"{BASE}/notams/KJFK", headers=H)
data = r.json()
print(f"{data['total']} active NOTAMs at {data['icao']}"){
"raw": "A0373/26 NOTAMN\nQ) KZNY/QMXLC/IV/M/A/000/999/4038N07346W005\nA) KJFK\nB) 2603241038\nC) 2608311800\nE) TWY D HLDG PSN MARKINGS FOR RWY 13L/31R NORTH SIDE NOT STD",
"notam_id": "A0373/2026",
"notam_id_domestic": "03/0373",
"type": "N",
"location": "KJFK",
"effective": "202603241038",
"expiration": "202608311800",
"body": "TWY D HLDG PSN MARKINGS FOR RWY 13L/31R NORTH SIDE NOT STD",
"schedule": null,
"lower_limit": null,
"upper_limit": null,
"affected_fir": "KZNY",
"q_code": "QMXLC",
"qline": "KZNY/QMXLC/IV/M/A/000/999/4038N07346W005",
"scope": "AERODROME",
"status": "ACTIVE"
}Two things to internalise before writing any code against this.
An empty array is a normal response. 200 with "notams": [] means no active notices, not an error. Small airports frequently have none.
Almost every field except raw can be null. The items are present only when the originator filed them, and different authorities file differently. Write every accessor defensively; notam["q_code"].startswith("QM") will throw on a large fraction of US domestic notices.
The Q-code is your classifier, when it exists
The Q-line is the machine-readable part of a NOTAM, and q_code is the five-character field inside it that says what the notice is about. QMXLC breaks down as Q + a two-letter subject + a two-letter condition.
MX is the subject — taxiway. LC is the condition — closed. So QMXLC is "taxiway closed", and you know that without reading a word of English.
The subject letters cluster usefully. MR is runway, MX taxiway, MN apron, FA aerodrome facilities, IC ILS, LC approach lighting, NA navigation aids, RT restricted airspace, WU unmanned aircraft activity, OB obstacles. The condition letters are similarly systematic: LC closed, AS unserviceable, LT limited, CH changed, XX plain-language remainder.
That structure lets you write classification that generalises rather than matching strings:
RUNWAY_SUBJECTS = {"MR", "MS", "MU", "MT"} # runway surface, stopway, strip
TAXIWAY_SUBJECTS = {"MX", "MY"}
NAVAID_SUBJECTS = {"IC", "ID", "IG", "II", "IL", "IM", "IN", "IO", "IS", "IT", "IU", "IW", "IX", "IY"}
LIGHTING_SUBJECTS = {"LA", "LB", "LC", "LD", "LE", "LF", "LH", "LI", "LJ", "LK", "LL", "LM", "LP", "LR", "LS", "LT", "LU", "LV", "LW", "LX", "LY", "LZ"}
CLOSED_CONDITIONS = {"LC", "AS", "AU", "AW"} # closed / unserviceable / withdrawn
def classify(notam):
q = notam.get("q_code")
if not q or len(q) != 5:
return "unclassified" # common on US domestic notices
subject, condition = q[1:3], q[3:5]
if subject in RUNWAY_SUBJECTS:
return "runway_closed" if condition in CLOSED_CONDITIONS else "runway"
if subject in TAXIWAY_SUBJECTS:
return "taxiway"
if subject in NAVAID_SUBJECTS:
return "navaid"
if subject in LIGHTING_SUBJECTS:
return "lighting"
return "other"The unclassified branch is not a rare edge case. Many US domestic notices are filed with no Q-line at all, so q_code, qline, and affected_fir are all absent. Any pipeline that classifies purely on Q-code will drop a large share of US NOTAMs on the floor, and you'll notice only when a customer asks why their home field shows nothing.
For those, you're back to keyword matching on body, which is unreliable in the usual ways but is what everyone does:
def classify_fallback(notam):
body = (notam.get("body") or "").upper()
if "RWY" in body and ("CLSD" in body or "CLOSED" in body):
return "runway_closed"
if "TWY" in body:
return "taxiway"
if any(k in body for k in ("ILS", "VOR", "NDB", "DME", "GPS", "RNAV")):
return "navaid"
return "unclassified"Aviation abbreviations are not consistent — CLSD, CLOSED, and CLOSED UNTIL FURTHER NOTICE all appear — so treat this as a heuristic that improves coverage rather than a parser.

Filter server-side before you filter client-side
The single biggest win is not fetching the noise in the first place. Two parameters do this.
exclude_qcode drops Q-code prefixes before the response is built. Passing QK removes every QK* notice — the monthly checklist NOTAMs that exist for administrative completeness and mean nothing to a pilot looking at today's operation. You can also pass an exact code.
exclude_scope drops whole scope categories. AERODROME notices affect the airport directly; FIR notices are en-route airspace items filed under the region. For an airport briefing panel, FIR notices are usually background.
r = requests.get(f"{BASE}/notams/EGLL", headers=H, params={
"exclude_qcode": "QK", # monthly checklist notices
"exclude_scope": "FIR", # en-route items, not this airport
})That one call typically removes a large slice of the volume before it reaches your code, and it costs nothing in latency or payload.
Resist the temptation to exclude more than you understand, though. exclude_scope=FIR is right for an airport panel and wrong for a route briefing, where airspace notices are exactly what you need.
Future notices are a separate question
By default you get only what is in effect right now, which matches what an AIS briefing shows. A NOTAM filed today for a runway closure starting in two weeks does not appear.
For day-of operations that's correct. For planning it isn't, and include_future=true adds upcoming notices tagged status: "FUTURE":
r = requests.get(f"{BASE}/notams/EGLL", headers=H,
params={"include_future": "true"})
active = [n for n in r.json()["notams"] if n.get("status") != "FUTURE"]
future = [n for n in r.json()["notams"] if n.get("status") == "FUTURE"]Keep them in separate sections of your interface. A future closure rendered next to current items reads as a current closure, and that's a genuinely dangerous piece of UI. Expired notices are never returned either way, so you don't need to filter for those.
The parsing edge cases that will catch you
Times are not ISO 8601. effective and expiration come back in the provider's format — 202603241038 is 2026-03-24 10:38 UTC. Parse them explicitly rather than handing them to a generic date parser:
from datetime import datetime, timezone
def parse_notam_time(s):
if not s:
return None
if s.strip().upper() in ("PERM", "PERMANENT"):
return None # no end date — see below
try:
return datetime.strptime(s[:12], "%Y%m%d%H%M").replace(tzinfo=timezone.utc)
except ValueError:
return NonePERM is a real expiration value. A permanent change has no end date. Code that does expiration < now to test currency will throw or, worse, silently treat a permanent notice as expired. Handle it as an explicit case.
The D) schedule field means the notice is intermittent. A NOTAM effective from March to August with schedule: "MON-FRI 0600-1800" is not in force at 2am on a Sunday. Most notices run continuously between B) and C) and have schedule: null, but when it's populated, "active" is a narrower question than "between the effective dates". Parsing those schedule strings properly is genuinely fiddly — they come in many shapes — and for most applications the honest move is to surface the schedule text to the user rather than pretend you've evaluated it.
Vertical limits only appear on airspace notices. lower_limit and upper_limit use aviation notation — SFC for surface, FL050, UNL for unlimited. They're null on the majority of aerodrome notices, and they are not numbers.
raw is multi-line. Render it in a monospace block without re-wrapping. Re-flowing a NOTAM changes how it reads and pilots are trained on the standard layout.

Putting a relevance filter together
For a briefing panel, the useful output is a short ranked list rather than everything that came back:
PRIORITY = {"runway_closed": 0, "runway": 1, "navaid": 2,
"lighting": 3, "taxiway": 4, "other": 5, "unclassified": 5}
def brief(icao, include_future=False):
r = requests.get(f"{BASE}/notams/{icao}", headers=H, params={
"exclude_qcode": "QK",
"exclude_scope": "FIR",
"include_future": str(include_future).lower(),
})
r.raise_for_status()
items = []
for n in r.json().get("notams", []):
kind = classify(n)
if kind == "unclassified":
kind = classify_fallback(n)
items.append({
"id": n.get("notam_id") or n.get("notam_id_domestic"),
"kind": kind,
"text": n.get("body") or n["raw"],
"schedule": n.get("schedule"), # None = continuous
"status": n.get("status", "ACTIVE"),
"raw": n["raw"],
})
items.sort(key=lambda i: (i["status"] == "FUTURE", PRIORITY[i["kind"]]))
return items
for n in brief("EGLL")[:8]:
flag = " [scheduled]" if n["schedule"] else ""
print(f"{n['kind']:>14} {n['id']}{flag} {n['text'][:70]}")Two deliberate choices in that. Future notices sort to the bottom rather than being hidden, so a planner can still see them. And raw is retained on every item even though the list renders body, because the detail view must be able to show the notice exactly as filed — a summarised NOTAM is not a substitute for the real one, and anyone making an operational decision needs the original.
A word on what not to automate
It's tempting to go further: parse the runway designator out of the body, cross-reference the active runway, and tell the user whether their departure is affected. Some of that works. Extracting RWY 13L/31R from the text is reliable enough.
Deciding what it means is not. NOTAMs carry conditional language, reference other notices, and sometimes describe situations that need judgement to interpret. A system that reads "TWY D HLDG PSN MARKINGS FOR RWY 13L/31R NORTH SIDE NOT STD" and renders "Runway 13L closed" is worse than one that shows the text, because it's confidently wrong in a domain where that matters.
Classify and rank, then show the notice. Leave the interpretation to the person who is responsible for the flight.
Getting started
The NOTAM endpoint is on every plan including the free trial, and because exclude_qcode and exclude_scope cut the payload server-side, a briefing panel is cheap to run. Cache for a few minutes rather than a few seconds — notices change over operational timescales, not by the second.
For the wider picture, the NOTAM API overview covers the structured-versus-raw argument, and TFRs handles the temporary flight restriction case, which behaves differently enough to be worth treating separately.
