An aircraft overhead is transmitting several messages per second on 1090 MHz, and each one is 112 bits — 14 bytes, 28 hexadecimal characters. That's the entire payload. Everything a flight tracker shows you about a live aircraft's position originates in those 14 bytes, repeated a few times a second, and most of what it shows beyond position is not in there at all.
This post takes real frames apart field by field. If you've been reading scattered PDFs and the odd forum post trying to work out why your decoder produces an aircraft somewhere in the North Sea when it should be over Frankfurt, the answer is probably in the position encoding section.
Every hex string, field offset, and decoded value below was produced by running the code in this post against those frames, not transcribed from a reference.
The physical layer, briefly
1090 MHz Extended Squitter uses pulse position modulation at 1 Mbit/s. Each bit occupies 1 microsecond, and the value is carried by which half of that microsecond holds the pulse. A frame opens with an 8 µs preamble of four pulses in a fixed pattern, which is what a receiver locks onto, followed by the 112 µs data block. Total occupancy: 120 microseconds.
Two consequences of that design matter in practice. First, there's no channel arbitration. Every transponder within earshot transmits whenever it feels like it, and 1090 MHz is shared with Mode A/C replies, Mode S interrogation replies, and TCAS coordination. Over a busy terminal area, transmissions overlap and mangle each other, and a meaningful fraction of frames are simply lost to collisions. This is why receiver density matters more than receiver sensitivity: the fix for a garbled frame is another receiver that heard a clean copy, not a better antenna.
Second, it's line of sight. The radio horizon works out to about 1.23 × √(altitude in feet) nautical miles, so an aircraft at FL350 is theoretically receivable from around 230 nm over flat terrain, while a drone at 200 feet behind a building is invisible from a mile away.
The 112-bit frame
Every extended squitter has the same outer structure:
| Field | Bits | Width | What it holds |
|---|---|---|---|
DF | 1–5 | 5 | Downlink Format — 17 for ADS-B from a Mode S transponder |
CA | 6–8 | 3 | Transponder capability |
ICAO | 9–32 | 24 | The aircraft's 24-bit address |
ME | 33–88 | 56 | The message payload |
PI | 89–112 | 24 | Parity / CRC |
The 24-bit ICAO address is the only stable identifier in the whole system. It's assigned to the airframe by its registering authority, it doesn't change when the flight number changes, and it's the key you use for looking up registration and type — because none of that is broadcast.
DF17 is ADS-B proper, from an aircraft with a Mode S transponder. You'll also see DF18, which has the identical bit layout but carries a Control Field in place of CA; it's used for TIS-B (ground-derived traffic rebroadcast), ADS-R (rebroadcast across link types), and non-transponder emitters like ground vehicles and obstacle beacons. DF19 is reserved for military use. If you're writing a decoder, handle DF17 and DF18 and note which one a track came from, because a DF18 position may be a ground station's estimate rather than an aircraft's own GNSS fix.
Take a real frame:
8D40621D58C382D690C8AC2863A7Split it into the fields above:
8D -> 1000 1101 DF = 10001 = 17, CA = 101 = 5
40621D -> ICAO address 40621d
58C382D690C8AC -> ME (56 bits)
2863A7 -> parityThe parity field is a plain CRC on DF17
Mode S uses a 24-bit CRC with generator polynomial 0xFFF409. On several downlink formats the aircraft address is XORed into the parity, so you recover the address by computing the CRC and XORing it back out — which also means you can't independently verify those frames.
DF17 and DF18 are the pleasant case: the parity is the CRC with nothing overlaid. Compute the remainder over the first 88 bits and it should equal the last 24 bits exactly. For the frame above, both are 2863A7. This is your first and most valuable filter — a frame whose CRC doesn't match is corrupt, and given how much collision-induced corruption happens on a busy 1090 MHz channel, silently accepting bad frames is the fastest route to aircraft appearing in the wrong ocean.
GEN = 0xFFF409 # 24-bit Mode S generator polynomial
def crc_ok(msg_hex: str) -> bool:
"""DF17/DF18 parity is a straight CRC — no address overlay."""
data = bytearray.fromhex(msg_hex)
data[-3:] = b"\x00\x00\x00" # blank the parity field
bits = [(b >> (7 - i)) & 1 for b in data for i in range(8)]
gen = [1] + [int(c) for c in format(GEN, "024b")]
for i in range(len(bits) - 24):
if bits[i]:
for j in range(25):
bits[i + j] ^= gen[j]
rem = 0
for b in bits[-24:]:
rem = (rem << 1) | b
return rem == int(msg_hex[-6:], 16)
print(crc_ok("8D40621D58C382D690C8AC2863A7")) # TrueType codes: what kind of message is this
The first 5 bits of the ME field are the Type Code, and they tell you how to read the remaining 51.
| TC | Contents |
|---|---|
| 1–4 | Aircraft identification (callsign and emitter category) |
| 5–8 | Surface position |
| 9–18 | Airborne position, barometric altitude |
| 19 | Airborne velocity |
| 20–22 | Airborne position, GNSS height |
| 28 | Aircraft status (emergency, squawk) |
| 29 | Target state and status (selected altitude, autopilot modes) |
| 31 | Aircraft operational status |
Within TC 9–18 and 20–22, the specific value also encodes navigational accuracy, which is how you know whether a position is trustworthy — a TC 18 airborne position carries no accuracy guarantee at all, while TC 11 corresponds to a well-constrained fix.

Identification: the callsign message
TC 1–4, sent about once every 5 seconds. The payload is 5 bits of type code, 3 bits of emitter category, then 8 characters at 6 bits each — 48 bits of text, which is exactly why callsigns are 8 characters and space-padded.
The character set is its own thing, not ASCII: 1–26 map to A–Z, 32 is space, 48–57 are the digits.
CHARSET = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######"
def bits_of(msg_hex):
return format(int(msg_hex, 16), f"0{len(msg_hex) * 4}b")
def callsign(msg_hex):
me = bits_of(msg_hex)[32:88]
chars = [CHARSET[int(me[8 + i * 6: 14 + i * 6], 2)] for i in range(8)]
return "".join(chars).replace("_", " ").strip()
print(callsign("8D4840D6202CC371C32CE0576098")) # KLM1023The emitter category is coarse — the type code selects one of four category sets and the 3-bit field picks within it, giving you buckets like "light", "heavy", "rotorcraft", "glider", "UAV", "surface vehicle". It is not an aircraft type. Nothing in the broadcast tells you the aircraft is a 737-800.
Note also that the callsign here is a flight identifier the crew types in, not the registration and not necessarily the commercial flight number, which is a distinction worth understanding before you key anything on it.
Airborne position
TC 9–18, twice per second. The 56-bit payload:
| Field | Width | Meaning |
|---|---|---|
| Type code | 5 | 9–18 |
| Surveillance status | 2 | Emergency / SPI indication |
| Single antenna flag | 1 | |
| Altitude | 12 | Encoded, see below |
| Time synchronisation | 1 | |
| CPR format | 1 | 0 = even frame, 1 = odd frame |
| Encoded latitude | 17 | CPR |
| Encoded longitude | 17 | CPR |
The 12-bit altitude and the Q bit
Bit 8 of the 12-bit altitude field is the Q bit, and it selects the encoding. When Q is 1, remove the Q bit to leave an 11-bit value N, and the altitude is 25 × N − 1000 feet. When Q is 0 you're in Gillham (Gray) coding at 100-foot resolution, which survives from the Mode C era and is rare above 50,175 feet but does still appear.
def altitude_ft(msg_hex):
me = bits_of(msg_hex)[32:88]
alt = me[8:20]
if alt[7] == "1": # Q bit set: 25 ft increments
n = int(alt[:7] + alt[8:], 2) # splice out the Q bit -> 11 bits
return 25 * n - 1000
return None # Gillham-coded, 100 ft increments
print(altitude_ft("8D40621D58C382D690C8AC2863A7")) # 3800038,000 feet. And this is the moment to say clearly what that number is: pressure altitude on the 1013.25 hPa standard datum. It is not height above sea level, and below the transition altitude it will differ from the aircraft's real height by roughly 27 feet per hPa of local QNH deviation. Every argument about two trackers showing different altitudes traces back to this field and what each of them chose to do with it.
CPR: fitting a global position into 17 bits
Here is the part that breaks decoders.
A latitude to 5-metre resolution needs about 22 bits. A longitude needs more. The message budget is 17 bits each. Compact Position Reporting resolves this by not transmitting a global position at all: it transmits a position within a grid cell, and leaves you to work out which cell.
The world is divided into 60 latitude zones for even-numbered frames and 59 for odd. Even zones are 6° of latitude tall; odd zones are 360/59 ≈ 6.101695°. Within a zone, the 17-bit value is the fractional position, giving 6° / 2¹⁷ ≈ 5.1 metres of resolution at the equator. Longitude works the same way, except the number of longitude zones varies with latitude — 59 at the equator, shrinking toward the poles so cells stay roughly square. That count is the NL function, and it's the piece most home-grown decoders get wrong.
Because the two frame types use slightly different zone heights, a matched pair of even and odd frames is enough to determine which zone the aircraft is in. That's the whole trick: two measurements against two rulers with different graduations pin down the absolute value.
import math
NZ = 15
def nl(lat):
"""Number of longitude zones at a given latitude."""
if lat == 0: return 59
if abs(lat) == 87: return 2
if abs(lat) > 87: return 1
a = 1 - math.cos(math.pi / (2 * NZ))
b = math.cos(math.pi / 180.0 * abs(lat)) ** 2
return int(math.floor(2 * math.pi / math.acos(1 - a / b)))
def decode_position(even_hex, odd_hex, even_is_newer=True):
"""Global CPR decode from an even/odd frame pair."""
me_e, me_o = bits_of(even_hex)[32:88], bits_of(odd_hex)[32:88]
lat_e, lon_e = int(me_e[22:39], 2) / 131072.0, int(me_e[39:56], 2) / 131072.0
lat_o, lon_o = int(me_o[22:39], 2) / 131072.0, int(me_o[39:56], 2) / 131072.0
j = math.floor(59 * lat_e - 60 * lat_o + 0.5) # latitude zone index
lat_even = (360.0 / 60) * (j % 60 + lat_e)
lat_odd = (360.0 / 59) * (j % 59 + lat_o)
if lat_even >= 270: lat_even -= 360
if lat_odd >= 270: lat_odd -= 360
# If the two frames fall in different longitude-zone counts, the aircraft
# crossed a boundary between them. The pair is unusable — discard it.
if nl(lat_even) != nl(lat_odd):
return None
if even_is_newer:
lat, ni = lat_even, max(nl(lat_even), 1)
m = math.floor(lon_e * (nl(lat_even) - 1) - lon_o * nl(lat_even) + 0.5)
lon = (360.0 / ni) * (m % ni + lon_e)
else:
lat, ni = lat_odd, max(nl(lat_odd) - 1, 1)
m = math.floor(lon_e * (nl(lat_odd) - 1) - lon_o * nl(lat_odd) + 0.5)
lon = (360.0 / ni) * (m % ni + lon_o)
if lon >= 180:
lon -= 360
return round(lat, 5), round(lon, 5)
print(decode_position("8D40621D58C382D690C8AC2863A7",
"8D40621D58C386435CC412692AD6"))
# (52.2572, 3.91937)52.2572° N, 3.91937° E — the North Sea off the Dutch coast, from two frames and nothing else.
The failure modes, which are the interesting part
You need both frame types, recent. No even/odd pair means no global decode. Convention is to require the two frames within 10 seconds of each other, because the method assumes the aircraft hasn't moved appreciably between them. Aircraft alternate even and odd frames, so at 2 messages per second this is normally fine — until reception is patchy and you're getting one frame in five, at which point you may go a minute without a usable pair while individual frames arrive perfectly well.
The NL check is not optional. If the aircraft crossed a longitude zone boundary between the two frames, nl(lat_even) != nl(lat_odd) and the pair must be thrown away. Decoders that skip this check produce positions that are wrong by a longitude zone — around 366 nm at the equator.
A wrong latitude zone puts you 360 nm out. The j index selects the zone. Get it wrong by one and the position is off by exactly one zone height: 6° of latitude, which is 360 nautical miles. This is the signature of a CPR bug, and it's why "the aircraft jumped several hundred miles" is such a recognisable symptom — it's almost never a random error, it's a quantised one. If you see jumps clustering near 360 nm, look at your zone arithmetic, not your feed.
Local decoding is the alternative. With a known reference position within about 180 nm — your receiver's location, or the aircraft's last good fix — you can decode a single frame without a pair, because the ambiguity is resolved by proximity. It's faster and works with one message, and it fails silently and spectacularly if the reference is wrong, since it will confidently hand you the nearest of the repeating candidate positions.

Velocity
TC 19, twice per second, and mercifully free of CPR. Subtypes 1 and 2 carry ground speed as separate east–west and north–south components, each with a sign bit and a 10-bit magnitude offset by one, in 1-knot units for subtype 1 and 4-knot units for supersonic subtype 2. Ground speed and track come out of the vector. Subtypes 3 and 4 carry airspeed and heading instead, used when the aircraft has no ground-referenced velocity.
The same message carries a 9-bit vertical rate in 64 ft/min units with a sign bit, a flag saying whether that rate is barometric or geometric, and the difference between GNSS height and barometric altitude — which is the field that lets you reconcile the two altitude worlds if you care to.
def velocity(msg_hex):
me = bits_of(msg_hex)[32:88]
if int(me[5:8], 2) not in (1, 2):
return None # airspeed subtype, decode differently
v_ew = int(me[14:24], 2) - 1
v_ns = int(me[25:35], 2) - 1
vx = -v_ew if me[13] == "1" else v_ew
vy = -v_ns if me[24] == "1" else v_ns
heading = (math.degrees(math.atan2(vx, vy)) + 360) % 360
vr = (int(me[37:46], 2) - 1) * 64
return round(math.hypot(vx, vy), 1), round(heading, 2), -vr if me[36] == "1" else vr
print(velocity("8D485020994409940838175B284F"))
# (159.2, 182.88, -832)159 knots on a track of 183°, descending at 832 feet per minute. That aircraft is on approach.
What is not in the broadcast
This is the section that matters most if you're deciding whether to build on raw frames or on an API, because the gap between "decoded ADS-B" and "useful flight data" is almost entirely made of things that are never transmitted:
- Registration. Not broadcast. You get a 24-bit hex address and look it up in a registry.
- Aircraft type. Not broadcast beyond the coarse emitter category. A "heavy" could be a 777 or an A330.
- Airline. Inferred from the callsign prefix, and the inference fails for charters, wet leases, and business aviation.
- Origin and destination. Not broadcast at all. Ever. Every route shown on every flight tracker is inferred or sourced from schedule data.
- Scheduled and estimated times. Not broadcast. These come from airline and airport feeds.
- Whether the flight is delayed, cancelled, or diverted. Not broadcast.
An aircraft transmits where it is, how fast, which way, how high, and what its crew typed into the FMS. Everything else on a flight tracking page was joined on from somewhere else. That join — hex address to registry to operator to schedule — is most of the work in a flight data product, and it's the reason raw feeds and enriched feeds are different categories of thing rather than different price points.
A note on 978 MHz
Everything above describes 1090 MHz Extended Squitter, the global standard. The US also runs UAT on 978 MHz for general aviation below 18,000 feet, and it is a completely different protocol — different modulation, different frame sizes, its own message structure, plus uplink services carrying weather and traffic that 1090 has no equivalent for. A 1090 decoder will not touch UAT traffic, which is a common reason US GA aircraft appear missing from feeds built on 1090 alone.
Where this leaves you
Writing a decoder is a genuinely good way to understand the system, and for a receiver on your own roof it's the right call — dump1090 and its descendants already do all of the above, well, and you can read the source.
Building a production data product out of it is a different proposition. The decoding is the easy part. What follows is receiver networks, MLAT for aircraft that broadcast no position, fusion across overlapping receivers, registry joins, schedule correlation, and continuous filtering of frames that pass CRC but still contain nonsense. That last one is its own discipline, covered in handling gaps, jumps, and bad positions, and the economics of doing it yourself are worked through in build vs buy for ADS-B ingestion.
If you'd rather have the decoded, deduplicated, registry-joined version, the ADS-B endpoint returns exactly the fields discussed here — icao24, position, barometric altitude, ground_speed, track, vertical_rate — with registration, type, and operator already resolved. The free trial needs no card, and if you want to check our decoding against your own receiver, that's a comparison we're happy to have run.
