You are about to put something on the air in Germany. A LoRa node in a factory, a 5.8 GHz link across a yard, an SDR pointed at a band you have not used before. The question you need answered is not "may I" but "who else is already here". The official answer is 753 pages of A4.
So I parsed all of it. Below are three tools on one dataset: an explorer for a band, a lookup for a single frequency, and a wall chart for the whole landscape.
The source is the Frequenzplan, the Bundesnetzagentur’s division of 0 kHz to 3000 GHz into permitted uses under § 90 TKG. The current edition is stamped August 2026 and holds 1,997 numbered entries across 522 sub-plans.
Why one number is never the answer
Split the plan at every distinct band edge and you get 1,127 segments. Only 94 of them carry exactly one entry. 1,026 carry two or more. Ninety-one per cent of the plan is shared in some way.
So “what is allocated at 2.44 GHz” has no single answer. It has ten, among them amateur radio, business radio, short-range devices, WLAN, motion detectors, military applications and reportage links. A frequency lookup returns a set, not a value.
The deepest point is 9325 to 9475 MHz, where twelve apply at once: weather, ship, pilot and air-traffic radar, Earth exploration, public-safety radio, military use, and that schools allocation again.
Tool 1: explore a band
When the question is “what does this band look like”. The strip at the top is the whole plan on a log axis; drag on it, or use a preset, to pick a window. The chart below is linear inside that window, which is what you want for comparing bandwidths and channel rasters. Click any block for the full entry, including its Frequenznutzungsbedingungen: channel spacing, duplex mode, maximum radiated power.
The track under the chart, marked Sonstige Funkanwendungen, is that Section 7 layer. Easy to miss in the PDF, and often the part that applies to you.
Tool 2: look up a frequency
Type a frequency, get everything the plan permits there, with the Funkdienst and the civil or military designation for each. Reach for this when you have a specific number in hand.
The profile behind it counts entries across the whole plan, so you can see whether you are wandering into quiet spectrum or crowded.
Two limits before you trust it. The count is entries in a document, not transmitters in the air: twelve does not mean twelve systems radiating next to you, since most are separated geographically, operationally, or by primary and secondary status. And it covers only sections 4 to 6. Section 7 layers on forty more applications, including UWB across 30 MHz to 3000 GHz, so the real number of rules touching your frequency is always at least this.
Tool 3: the whole landscape on one sheet
For planning, for the wall, for seeing where your band sits relative to everything else. Nine rows, one per ITU band, linear inside the row: a log axis spends half its width on spectrum nobody argues about, where nine linear rows give every decade the full sheet and the room to print band edges and names.
The gold band running the whole width of the 3 to 30 MHz row is a single entry: 3004, Funkanwendungen der BOS, 9 kHz to 30 MHz on individually assigned frequencies. Everything else in HF stacks on top of it.
How current is this?
Every entry carries a Stand, the month its sub-plan was last revised. In the August
2026 edition, 1,996 of the 1,997 entries read MÄRZ 2022.
One reads AUGUST 2026: entry 318003, 3800 to 4200 MHz, Terrestrische drahtlose
Breitbandsysteme, civil. Sub-plan 318 already held two entries in that exact range,
fixed links and satellite service connections, both from 2022. The 2026 change did not
replace either. It added a third.
So the plan moves slowly, and when it moves it adds a layer rather than clearing one. Treat these tools as current, and check the published edition before anything load-bearing.
Where the data comes from
The PDF is a text PDF with a rigid table layout, which makes pdftotext -layout a
better tool than any PDF library:
pdftotext -layout 202608_Frequenzplan.pdf full.txt
grep -c "Frequenzteilplan:" full.txt # 1997
python3 parse_frequenzplan.py full.txt frequenzplan.json
Every entry is a fixed sequence of labelled fields, so parsing is splitting on labels
and accumulating continuation lines. Two things cost me time. Section 7 uses a
different table shape and no Frequenzteilplan: header, so an entry scan runs past the
end of section 6 and swallows forty applications into the last entry. And one range, in
entry 488005, reads 171.11 - 171,45 GHz, a decimal point where the rest of the
document uses a comma; it parses as 17,111 GHz and sorts to a band that does not exist.
The parser catches that because it treats an inverted range as an error rather than
sorting it quietly into the wrong decade, which is the habit I keep
relearning.
The dataset is public: 1,996 entries with a sub-range plus the 40 Section 7 applications, each with its Funkdienst, permitted use and conditions. Colour groupings are mine; every other field is the source’s wording.
The parser, in full (Python, no dependencies)
#!/usr/bin/env python3
"""Parse the Bundesnetzagentur Frequenzplan PDF into the dataset the blog post's
interactive figures read.
pdftotext -layout 202608_Frequenzplan.pdf full.txt
python3 scripts/parse_frequenzplan.py full.txt public/data/frequenzplan-2026-08.json
The document is a text PDF with a rigid table layout, so `pdftotext -layout`
plus label-splitting beats any PDF library here. Two shapes exist:
Sections 4-6 numbered entries, each opened by a
"Frequenzteilplan: N Eintrag: NNNNNN Stand: MONAT JAHR" header.
Section 7 "Sonstige Funkanwendungen" - cross-cutting applications with no
Frequenzteilplan header and a different field set. The entry scan
MUST stop where this starts, or the last entry of section 6
swallows all forty of them.
Group names and colours are ours; every other field is the source's wording,
unchanged. Colours are the validated categorical palette used across the site's
charts, in fixed slot order.
"""
import json
import re
import sys
from collections import Counter
MUL = {'kHz':1e3,'MHz':1e6,'GHz':1e9,'Hz':1.0}
UNIT = r'(kHz|MHz|GHz|Hz)'
R_BOTH = re.compile(r'^([\d.,]+)\s*'+UNIT+r'?\s*[-–]\s*([\d.,]+)\s*'+UNIT+r'\s*$')
def num(s, dot_dec=False):
return float(s.replace(',','.')) if dot_dec else float(s.replace('.','').replace(',','.'))
def parse_range(t):
t = t.strip().replace('–','-').replace('−','-')
m = R_BOTH.match(t)
if not m: return None
lo_s, lo_u, hi_s, hi_u = m.group(1), m.group(2), m.group(3), m.group(4)
lo, hi = num(lo_s)*MUL[lo_u or hi_u], num(hi_s)*MUL[hi_u]
if hi < lo: # source typo: '.' used as decimal comma
lo, hi = num(lo_s, True)*MUL[lo_u or hi_u], num(hi_s, True)*MUL[hi_u]
return (lo, hi)
def clean_lines(raw):
out=[]
for l in raw.split('\n'):
s=l.strip()
if s.startswith('© Bundesnetzagentur'): continue
if re.match(r'^Stand:?\s*\S+\s*\d{4}\s*©', s): continue
if re.fullmatch(r'-\s*\d+\s*-', s): continue
out.append(l.replace('\f',''))
return out
# ---------- groups ----------
# Validated light categorical palette (dataviz reference instance), in fixed
# slot order, the ordering is the CVD-safety mechanism, not cosmetic. Checked
# against the chart surface #fcfcfb: lightness band, chroma floor, CVD
# separation and normal-vision floor all pass. Three slots sit below 3:1
# against the surface, which the relief rule permits because every figure ships
# direct labels, a legend and a table view.
# `ink` is the label colour for text drawn on top of that block, picked by
# measured contrast rather than by eye.
GROUPS = [
("betrieb", "Business & fixed radio", "Betriebs- und Bündelfunk", "#2a78d6", "#111111"),
("mil", "Military", "Militärische Funkanwendungen", "#eb6834", "#111111"),
("wiss", "Science & Earth observation", "Wissenschaft und Erdbeobachtung", "#1baf7a", "#111111"),
("verkehr", "Transport, navigation & safety", "Verkehr, Navigation und Sicherheit", "#eda100", "#111111"),
("netz", "Public networks, broadcast & links", "Öffentliche Netze, Rundfunk, Richtfunk","#e87ba4", "#111111"),
("amateur", "Amateur radio", "Amateurfunk", "#008300", "#ffffff"),
("sat", "Satellite", "Satellitenfunk", "#4a3aa7", "#ffffff"),
("kurz", "Short-range & licence-exempt", "Kurzstrecken, SRD, ISM, WLAN, UWB", "#e34948", "#111111"),
("keine", "No use designated", "Keine Frequenznutzung ausgewiesen", "#7d7c74", "#ffffff"),
]
GI = {g[0]:i for i,g in enumerate(GROUPS)}
MAP = {
'Militärische Funkanwendungen':'mil',
'Betriebsfunk':'betrieb','Betriebsfunk/Bündelfunk':'betrieb','Funkruf':'betrieb','Datenfunk':'betrieb',
'Kurzstreckenfunk':'betrieb','Funknachrichten an einen oder mehrere Empfänger':'betrieb',
'Ortsfeste Tonübertragung':'betrieb','Fernmessen (Telemetrie)':'betrieb','Grubenfunk':'betrieb',
'Radioastronomie':'wiss','Erderkundung':'wiss','Weltraumforschungsfunk':'wiss','Wetterhilfenfunk':'wiss',
'Wetterradar':'wiss','Windprofil-Messradar':'wiss','Ozeanographische Radare':'wiss',
'Normalfrequenz- und Zeitzeichenfunk':'wiss','Funkanwendungen für Vermessungszwecke':'wiss',
'Bodenüberwachungsradare (GBSAR)':'wiss','Kurzzeitpeilfunk':'wiss',
'Intersatellitenfunk':'sat','Serviceverbindungen im Satellitenfunk':'sat',
'Speiseverbindungen im Satellitenfunk':'sat','Meteorologischer Satellitenfunk':'sat',
'Satellitennavigation':'sat','VSAT-Funkanwendungen':'sat','Satellitenrundfunk (TK)':'sat',
'Weltraumfernwirkfunk':'sat',
'Flugfunk':'verkehr','Flugnavigation':'verkehr','Flugsicherungsradar':'verkehr','Flugzeugradar':'verkehr',
'Helikopterradare':'verkehr','Seefunk':'verkehr','Binnenschifffahrtsfunk':'verkehr','Schiffsradar':'verkehr',
'Lotsenradar':'verkehr','Funkfeuer':'verkehr','Navigationsfunk':'verkehr','BNK-Radare':'verkehr',
'Funkanwendungen der Eisenbahnen':'verkehr','Funkanwendungen für Verkehrstelematik':'verkehr',
'Funkanwendungen für intelligente Verkehrssysteme':'verkehr','Rettungsfunk in Luft- und Seefahrzeugen':'verkehr',
'Such- und Rettungsfunk':'verkehr','Funkanwendungen der BOS':'verkehr','Funkanwendungen der BOS (AAEW)':'verkehr',
'Funkanwendungen für Alarmierungszwecke':'verkehr','Ortung von Verschütteten':'verkehr',
'Auffinden von Lawinenverschütteten':'verkehr','Funkanwendungen der Avionik (WAIC)':'verkehr',
'Radare zur Detektion von fliegenden Objekten geringer Größe':'verkehr','Tankradare':'verkehr',
'Drahtloser Netzzugang zum Angebot von Telekommunikationsdiensten':'netz',
'Terrestrische drahtlose Breitbandsysteme':'netz','MWS-Funkanwendungen':'netz',
'Breitbandige ortsfeste drahtlose Funkanwendungen (BFWA)':'netz','Schnurlose Telekommunikation (DECT)':'netz',
'Ton-Rundfunk (TK)':'netz','Fernseh-Rundfunk (TK)':'netz','Reportagefunk':'netz','Funkmikrofone':'netz',
'Drahtlose Kameras':'netz','Richtfunk':'netz',
'Amateurfunk':'amateur',
'Funkanwendungen geringer Reichweite (SRD)':'kurz','ISM-Anwendungen':'kurz','WLAN':'kurz',
'UWB-Funkanwendungen':'kurz','Funkanwendungen für weitbandige Datenübertragung (MGWS)':'kurz',
'Fernsteuerung von Modellen':'kurz','Fernsteuerung von Flugmodellen':'kurz','Hörhilfen':'kurz',
'Induktive Funkanwendungen':'kurz','Infrarot-Funkanwendungen':'kurz','CB-Funk':'kurz',
'Funkanwendungen kleiner Leistung im Gesundheitsbereich':'kurz','Demonstrationsfunk für Bildungseinrichtungen':'kurz',
'Funkbewegungsmelder':'kurz','Funkbewegungsmelder geringer Reichweite':'kurz',
}
SRC = sys.argv[1] if len(sys.argv) > 1 else 'full.txt'
OUT = sys.argv[2] if len(sys.argv) > 2 else 'public/data/frequenzplan-2026-08.json'
raw = open(SRC, encoding='utf-8').read()
lines = clean_lines(raw)
# ---------- sections 4-6 ----------
hdr_re = re.compile(r'Frequenzteilplan:\s*(\S+)\s+Eintrag:\s*(\S+)\s+Stand:\s*(.+?)\s*$')
idx = [i for i,l in enumerate(lines) if hdr_re.search(l)]
# Section 7 uses a different table shape; stop the entry scan where it starts.
S7 = next(i for i,l in enumerate(lines)
if l.strip().startswith('Sonstige Funkanwendungen und andere Anwendungen') and i > idx[-1])
LABELS = [('Frequenzbereich:','bereich'),('Nutzungsbestimmung(en):','nb'),('Funkdienst:','funkdienst'),
('Nutzung:','nutzung'),('Frequenznutzung:','nutzungsart'),('Frequenzteilbereich(e):','teilbereich'),
('Frequenznutzungs-','bedingungen'),('bedingungen:','bedingungen')]
pref = re.compile(r'^((?:D?\d+[A-Z]?\s*)+):\s*')
entries=[]
for n,i in enumerate(idx):
end = idx[n+1] if n+1 < len(idx) else S7
block = lines[i:end]
m = hdr_re.search(block[0])
e = {'teilplan':m.group(1),'eintrag':m.group(2),'stand':m.group(3).strip()}
st={'k':None,'b':[]}
def flush():
if st['k']:
v='\n'.join(x.rstrip() for x in st['b']).strip()
e[st['k']] = (e[st['k']]+'\n'+v).strip() if e.get(st['k']) else v
for l in block[1:]:
s=l.strip(); hit=None
for lab,key in LABELS:
if s.startswith(lab): hit=(lab,key); break
if hit: flush(); st['k']=hit[1]; st['b']=[s[len(hit[0]):].strip()]
elif st['k']: st['b'].append(s)
flush()
for k in ('bereich','nb','funkdienst','nutzung','nutzungsart','teilbereich','bedingungen'): e.setdefault(k,'')
for k in ('nutzungsart','funkdienst'):
e[k]=e[k].replace('\n',' ').strip()
mm=pref.match(e[k])
if mm: e[k+'_nb']=mm.group(1).strip(); e[k]=e[k][mm.end():].strip()
entries.append(e)
out=[]; unmapped=Counter()
for e in entries:
r = parse_range(e['teilbereich'])
if not r: continue
ua = e['nutzungsart']
g = 'keine' if not ua else MAP.get(ua)
if g is None: unmapped[ua]+=1; g='kurz'
bed = re.sub(r'\n{2,}','\n', e['bedingungen']).strip()
out.append({'id':e['eintrag'],'tp':e['teilplan'],'lo':r[0],'hi':r[1],'u':ua,'g':GI[g],
'fd':e['funkdienst'],'n':e['nutzung'],'nb':e['nb'],'st':e['stand'],'c':bed})
print("sections 4-6:", len(out), "| unmapped:", dict(unmapped))
# ---------- section 7 ----------
s7start = S7
s7end = next((i for i,l in enumerate(lines[s7start:], s7start) if 'Zitierte Nutzungsbestimmungen' in l), len(lines))
cat=None; items=[]; cur=None; f=None; buf=[]
def f7():
if cur is not None and f: cur[f]='\n'.join(x.rstrip() for x in buf).strip()
for l in lines[s7start:s7end]:
s=l.strip()
if s.startswith('Sonstige Funkanwendungen und andere Anwendungen'): f=None; continue
m=re.fullmatch(r'-\s*(.+?)\s*-', s)
if m and len(s)>4:
cat=m.group(1); f=None; continue
if s.startswith('Frequenznutzung:'):
f7()
if cur: items.append(cur)
cur={'kategorie':cat}; f='u'; buf=[s[16:].strip()]
elif s.startswith('Frequenzteilbereich(e):'): f7(); f='teilbereich'; buf=[s[23:].strip()]
elif s.startswith('Frequenzteilbereich:'): f7(); f='teilbereich'; buf=[s[20:].strip()]
elif s.startswith('Frequenznutzungs-'): f7(); f='c'; buf=[s[17:].strip()]
elif s.startswith('bedingungen:'): f7(); f='c'; buf=[s[12:].strip()]
elif f: buf.append(s)
f7()
if cur: items.append(cur)
sonst=[]
for it in items:
it['u']=it.get('u','').replace('\n',' ').strip()
mm=pref.match(it['u'])
nb=''
if mm: nb=mm.group(1).strip(); it['u']=it['u'][mm.end():].strip()
r=parse_range(it.get('teilbereich','').split('\n')[0])
if not r: print(" skip s7:", it.get('teilbereich')); continue
g = MAP.get(it['u'])
if g is None: unmapped[it['u']]+=1; g='kurz'
sonst.append({'lo':r[0],'hi':r[1],'u':it['u'],'k':it['kategorie'],'g':GI[g],'nb':nb,
'c':re.sub(r'\n{2,}','\n', it.get('c','')).strip()})
print("section 7:", len(sonst), "| unmapped now:", dict(unmapped))
data = {
'meta': {
'source':'Frequenzplan gemäß § 90 TKG, Bundesnetzagentur',
'edition':'August 2026',
'url':'https://www.bundesnetzagentur.de/EN/Areas/Telecommunications/FrequencyManagement/SpectrumPlan/start.html',
'entries': len(out), 'sonstige': len(sonst),
'range':[0, 3000e9],
},
'groups': [{'k': k, 'label': l, 'de': d, 'color': c, 'ink': i}
for k, l, d, c, i in GROUPS],
'entries': out,
'sonstige': sonst,
}
json.dump(data, open(OUT, 'w'), ensure_ascii=False, separators=(',', ':'))
import os
print('wrote', OUT, os.path.getsize(OUT), 'bytes')
The Frequenzplan is © Bundesnetzagentur and only the published edition is legally binding. Nothing here is a substitute for reading it, but it is a much faster way to find out which page you need.