Six-digit catalog numbers broke your TLE parser

The two-line element format reserves exactly five characters for the catalog number. The five-digit range ran out at 69999, not 99999, on 11 July 2026, and every object catalogued since is numbered from 100000 upwards. That is six digits in a five-character field. There are two consequences, and the second one is the surprising one. Some sources encode the overflow as Alpha-5, replacing the leading digit with a letter, so 100000 arrives as A0000 and an integer parse fails. Other sources, including CelesTrak, simply omit those objects from TLE output, so nothing fails: the object is just quietly not there.

The measurement that matters

We measured this against CelesTrak rather than repeating what the internet says. Asking for the same group in two formats gives two different catalogs:

  • On 5 August 2026 the last-30-days group returned 306 objects as JSON and 130 as TLE. The missing 176 are exactly the ones with six-digit catalog numbers, and not one line in the TLE output began with a letter.
  • Both totals move every day, because last-30-days is a rolling window. The gap is the point, not the totals. On 3 August the same two commands gave 375 and 199, and the difference was 176 then too. Run them yourself and check that the difference still equals the six-digit count.
  • If your pipeline reads TLE format, those objects are not malformed in your data. They are absent from it, and nothing in your logs says so.
  • This is the failure mode worth checking for first. A parser that crashes tells you it crashed; a feed that quietly dropped 176 of its 306 objects does not.
The same request, two formats
# JSON: every object in the group, six-digit catalog numbers included.
curl -s 'https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=json' | jq length

# TLE: the same group, minus every six-digit object, silently.
curl -s 'https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=tle' | grep -c '^1 '

What Alpha-5 is, exactly

Alpha-5 is the convention for squeezing a six-digit number into five characters: the leading digit becomes a letter, and the remaining four digits are unchanged. A is 10, B is 11, and so on, but I and O are skipped because they are indistinguishable from 1 and 0 in a fixed-width field. So the letters run A to H, then J to N, then P to Z, which puts the ceiling at Z9999, or 339,999.

The arithmetic, if you are writing the decoder: take the letter’s position in that 24-letter sequence, add 10, multiply by 10,000, and add the last four digits.

Decoding Alpha-5 in Python
ALPHA5 = "ABCDEFGHJKLMNPQRSTUVWXYZ"  # no I, no O

def parse_catalog_number(field: str) -> int:
    """Parse the 5-character TLE catalog field, Alpha-5 included."""
    field = field.strip().upper()
    if field.isdigit():
        return int(field)
    if len(field) != 5 or field[0] not in ALPHA5 or not field[1:].isdigit():
        raise ValueError(f"not a catalog number: {field!r}")
    return (ALPHA5.index(field[0]) + 10) * 10_000 + int(field[1:])

assert parse_catalog_number("25544") == 25544
assert parse_catalog_number("A0000") == 100000
assert parse_catalog_number("Z9999") == 339999
Decoding Alpha-5 in JavaScript
const ALPHA5 = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // no I, no O

export function parseCatalogNumber(field) {
  const raw = field.trim().toUpperCase()
  if (/^\d+$/.test(raw)) return Number.parseInt(raw, 10)
  const index = ALPHA5.indexOf(raw[0])
  if (raw.length !== 5 || index === -1 || !/^\d{4}$/.test(raw.slice(1))) {
    throw new Error(`not a catalog number: ${field}`)
  }
  return (index + 10) * 10_000 + Number.parseInt(raw.slice(1), 10)
}
Decoding Alpha-5 in C
/* field is the 5 characters at columns 3-7 of line 1, not NUL-terminated. */
static const char *ALPHA5 = "ABCDEFGHJKLMNPQRSTUVWXYZ"; /* no I, no O */

long parse_catalog_number(const char field[5]) {
    char buf[6];
    memcpy(buf, field, 5);
    buf[5] = '\0';

    if (isdigit((unsigned char)buf[0])) return strtol(buf, NULL, 10);

    const char *p = strchr(ALPHA5, toupper((unsigned char)buf[0]));
    if (!p) return -1;                      /* not a catalog number */
    return ((p - ALPHA5) + 10) * 10000L + strtol(buf + 1, NULL, 10);
}

The exact columns that break

The catalog number appears twice, once per line, at the same offsets in both. Everything in a TLE is positional, so a parser that splits on whitespace has been getting away with it and will keep getting away with it here; a parser that slices fixed columns needs no change at all, as long as the slice is not then passed to an integer conversion.

  • Line 1, columns 3 to 7 (0-indexed 2 to 6): catalog number. Column 8 is the classification letter, normally U.
  • Line 2, columns 3 to 7 (0-indexed 2 to 6): the same catalog number, and it must match line 1.
  • Neither line changes length. A TLE line is 69 characters before and after Alpha-5, so a length check is not the thing that will catch this.
  • The checksum is unaffected: letters contribute zero, exactly like the classification letter, blanks, periods and plus signs already do. A minus sign is the exception and still counts as 1, as it always has. Score it as zero and every line 1 fails: across the 260 lines of a live CelesTrak feed we checked, counting minus as 1 validated all 260, and counting it as zero validated only the 130 line 2s, which carry no minus signs at all.

What this catalog does about it

We serve six-digit objects, and we do not invent an encoding for them. Asking for a TLE for an object above 99,999 returns HTTP 422 with a pointer to the OMM endpoint, rather than an Alpha-5 line our own upstream declines to produce, and rather than the object silently missing from a list.

If you are handling six-digit catalog numbers, the durable answer is to stop reading fixed-width text. The CCSDS OMM JSON carries the catalog number as an integer with no width limit at all, which is the actual fix rather than a workaround for it.

Six-digit objects, as JSON
# A six-digit object. No encoding, no letters, just an integer.
curl -s 'https://www.orbitalwiki.com/api/v1/satellites/100000/omm'

# The same object as TLE says why it cannot, rather than guessing.
curl -s 'https://www.orbitalwiki.com/api/v1/satellites/100000/tle'
# {"error":"TLE format cannot encode this six-digit NORAD catalog ID", ...}

Paste a TLE and see what your parser is seeing

Nothing leaves your browser. Paste both lines to check that the catalog numbers agree.

Where to get the authoritative version

The catalog numbers themselves are assigned by the 18th Space Defense Squadron and published through Space-Track, which is the origin of the data almost everyone else redistributes. If you need the official record, decay predictions, or history that only the catalog owner holds, that is where it lives. The cost is registration and approval, and their documented limits are under 30 requests a minute and 300 an hour.

Source: Space-Track API documentation · CelesTrak, current element sets

Try this catalog in thirty seconds

curl
# Reads work with no key at 50 requests a day per IP. A free key raises it to 1,000.
curl -s 'https://www.orbitalwiki.com/api/v1/satellites/25544'

# Current orbital elements, as CCSDS OMM JSON
curl -s 'https://www.orbitalwiki.com/api/v1/satellites/25544/omm'

# Or as a two-line element set
curl -s 'https://www.orbitalwiki.com/api/v1/satellites/25544/tle?format=tle'
Python
import os
import requests

BASE = "https://www.orbitalwiki.com/api/v1"
# Optional. Without it you get 50 requests a day per IP; with it, 1,000 a day.
KEY = os.environ.get("ORBITALWIKI_API_KEY")
headers = {"Authorization": f"Bearer {KEY}"} if KEY else {}

r = requests.get(f"{BASE}/satellites", params={"limit": 100}, headers=headers, timeout=30)
r.raise_for_status()
page = r.json()

print(page["meta"]["total"], "objects in the catalog")
for sat in page["data"][:5]:
    print(sat["norad_cat_id"], sat["name"], sat["operator"])

# Paginate with the cursor the response hands you, rather than guessing offsets.
# The whole catalog is far more than 50 requests, so a keyless run meets its
# limit part way through. Check every response, and stop when told to.
while page["meta"]["has_more"]:
    r = requests.get(page["meta"]["next"], headers=headers, timeout=30)
    if r.status_code == 429:
        print("rate limited, retry in", r.headers.get("Retry-After"), "seconds")
        break
    r.raise_for_status()
    page = r.json()

Reads work without a key at 50 requests a day per IP address. A free key raises that to 1,000 requests a day, and going over returns 429 with a retry hint.

Checked against each provider’s own documentation on 2026-08-05. If a figure here is out of date, tell us at hello@orbitalwiki.com and we will correct it.