Absent is not zero
The feed is proto2. A field that was never set is omitted entirely — no occupancy reading is not 0% full, and no delay is not on time. Check presence, not truthiness.
GTFS-realtime is defined in proto2, where every field is explicitly
optional or required, and where “was this set?” is a
question the wire format can actually answer. Most protobuf tooling throws that away on decode:
ask for a field nobody set and you get 0, or "", or the first enum
value — indistinguishable from a field someone deliberately set to zero.
This API decodes with defaults off and re-applies only the defaults the .proto
actually declares. Everything else stays absent, and an absent field is omitted from the
JSON entirely rather than serialised as null.
Why it matters here
These are not hypothetical. Both mistakes produce a plausible-looking answer that is wrong in the direction that matters:
- No occupancy reading is not an empty vehicle. A UI that renders absent as “0% full” tells passengers there is room on a bus nobody has measured.
- No reported delay is not on time. Averaging absent-as-zero into a punctuality figure pulls it toward zero, making a service look more punctual the less it reports.
The five fields that do get a default
These are the only defaults the specification declares, so they are the only ones the API fills in. Everywhere else, absent stays absent.
| Field | Applied when absent |
|---|---|
incrementality (feed header) | FULL_DATASET |
currentStatus (vehicle) | IN_TRANSIT_TO |
scheduleRelationship (stop time update) | SCHEDULED |
cause (alert) | UNKNOWN_CAUSE |
effect (alert) | UNKNOWN_EFFECT |
What this capture actually omits
Measured across the whole capture. Every one of these is a field the SL feed never populates, and every filter over one of them therefore matches nothing:
| Field | Present on | Consequence |
|---|---|---|
occupancyStatus, occupancyPercentage | 0 of 1,116 vehicles | ?occupancy= returns an empty list for every value. |
congestionLevel | 0 of 1,116 vehicles | ?congestion= returns an empty list for every value. |
stopId, currentStopSequence (vehicle) | 0 of 1,116 vehicles | ?stopId= on vehicles matches nothing. Use
stop arrivals, which works off trip updates.
|
delay (trip level) | 0 of 934 trip updates | ?delayStatus=, ?minDelay= and ?maxDelay= all match
nothing. Delays are here — just on the stop time events instead.
|
severityLevel (alert) | 0 of 177 alerts | ?severity= matches nothing. Unlike cause/effect there
is no declared default, so the key is simply absent.
|
language (alert translations) | 0 tagged | ?lang= always falls through to the untagged translation. |
Each of these is flagged on its parameter row in the reference and badged in the Try it console, so an empty result is never ambiguous between “nothing matched your filter” and “this filter cannot match anything”.
Reading it correctly
// Wrong twice over: 0% reads as missing, and missing reads as empty.
const fullness = vehicle.occupancyPercentage || 0;
// Right: absence is its own case, and 0 is a real reading.
const fullness =
vehicle.occupancyPercentage === undefined
? "no reading"
: `${vehicle.occupancyPercentage}% full`;
// Same shape for delay. Note that 0 is meaningful — exactly on time.
if (trip.delay === undefined) {
// The feed said nothing. Not the same as "on schedule".
} else if (trip.delay > 60) {
// Genuinely running late.
}
// In TypeScript, `in` and optional chaining both do the right thing:
if ("occupancyStatus" in vehicle) { /* … */ }
const status = vehicle.trip?.route?.shortName ?? "unknown route";
The same rule governs the response envelope. A key you do not see in a response is a key the feed
did not supply — the API never emits null as a placeholder, so
"x" in obj and obj.x !== undefined agree, and neither can be fooled by a
legitimate zero.