Why an EPG shows the wrong time: XMLTV timestamps and offsets

A guide that is out by a whole number of hours is almost never a mystery. It is a suffix that was present in the file and not honored.

<programme start="20260719150000 +0200" stop="20260719160000 +0200" channel="one.example.com">

Fourteen digits, a space, then five characters that decide whether that row starts at 15:00 or at 13:00. The fourteen digits are not an instant. They are a wall-clock reading with no location attached, and a correct program guide depends on those last five characters being read, or on their absence being interpreted the way the grabber that wrote the file meant it.

Before changing anything, measure the shift. If every program on every channel is out by the same whole number of hours, one thing is wrong, and it is almost always an offset that was present in the file and not honored. If some channels are right and others are wrong, or one row is out and the rows either side of it are fine, that is a different failure and none of the offset work below will touch it.

Our player parses the guide on your device, against the address you point it at. We curate guide addresses, and the same parser runs on our side over the curated ones for catalog work. A guide address you supply is fetched and read on your machine, and we never hold your guide data.

A whole-guide shift and a single wrong program are different bugs

A uniform shift has three plausible sources and they are distinguishable in a minute.

The first is an offset the file carried and the reader threw away. The second is an offset the file never carried, where the grabber wrote local wall-clock times with no suffix and the reader had no way to know that. The third is a correction applied twice, once by the source and once by you, which is the one people reach for first and the one that produces a shift of exactly double whatever they set. If your guide moved from one hour out to three hours out after you set a two-hour correction, that is the third case, and the answer is to put the correction back to zero before diagnosing anything else.

Measuring it takes two comparisons and a clock. Pick a program that is on air right now and note how far its stated start is from the truth, then repeat on a second channel from the same source, then on a channel from a different source. Same error on both channels of one source and no error on the other source narrows it to that source's file. Same error everywhere points at the device clock instead, which is a different repair entirely.

A non-uniform shift is not a timezone problem at all. The usual cause is that the guide row is attached to the wrong channel, which makes a correct schedule for some other station look like a wrong schedule for yours. That is an identity failure, and its shape is the subject of a separate article. An id being present in the playlist is not the same as an id that matches your guide: in the larger of the two playlists we measured, 527 of 1,895 rows ended with no usable id at all. The other non-uniform causes are durations rather than start times, and they are covered further down.

A suffix that starts with a sign has told you it is a number

Here is the rule our timestamp reader applies, and the comment that sits above it, verbatim:

var suffix = value[14..].Trim();
if (suffix.Length > 0 && (suffix[0] == '+' || suffix[0] == '-'))
{
    // A suffix that looks like a numeric offset must read as one. Falling
    // through to the assumed timezone here is what used to shift a whole
    // feed by hours with nothing in the logs: the offset was present, said
    // +02:00, and was read as UTC.
    if (!TryParseOffset(suffix, out var offset))
    {
        return false;
    }

That comment describes our own bug. We got this wrong in an earlier version of this reader, which treated an unreadable offset the same way it treated no offset at all: fall through, assume UTC, carry on. The symptom was a guide displaced by the exact size of the offset the file had stated, on every channel, for every day in the feed, with a clean import summary and nothing in the log to look at. The cause was one missing early return. The cost was that the failure was undetectable from inside the app: no counter moved, no log line was written, and the guide stayed wrong until somebody held it up against a clock.

Refusing the row is worse for that one program and much better for the guide. A dropped program leaves a hole you can see. A silently reinterpreted feed is a schedule that is confidently and uniformly wrong, and no amount of staring at it tells you that.

That rule has a hole, and it is worth stating plainly. The check looks at the first character of the suffix only. A suffix written as GMT+0200, with the label before the sign rather than after it, does not begin with a sign, so it takes the quiet path. Run through the shipped parser it returns 15:00 UTC for a 15:00 local time, which is to say the offset is sitting there in the string and is ignored. No test pins that shape, and we hold no XMLTV corpus of our own, so the absence of a witness is not evidence that it is rare.

Three offset shapes are read, and seven are refused

After the sign, the reader takes everything up to the first space and measures the digit run. Only three lengths mean anything: two characters for an hours-only offset, four for hours and minutes packed together, and five when the third character is a colon. +02, +0200 and +02:00 all resolve to the same two hours. Anything under three characters in total is refused before the switch is reached, and every other length falls out of a default branch.

All three shapes are ISO 8601, which is the reason all three are read. +0200 is the basic form, +02:00 the extended one, +02 the hours-only one, and the XMLTV DTD writes its own example in the basic form, as +0300. Blessing one shape and quietly taking the other two for UTC would put every feed that chose differently an hour or more out.

Two tolerances sit around that. The space before the sign is optional, because the suffix is defined as everything from character fifteen onward, so 20260719150000+0200 works identically. And a zone label after a real offset is discarded rather than treated as damage: +0100 BST truncates at the space, reads +0100, and yields 14:00 UTC for a 15:00 local time. Half-hour zones fall out for free, since minutes are parsed as their own two digits, so +05:30 resolves correctly.

That character-fifteen boundary has a consequence nobody designed. The date core is taken as exactly the first fourteen characters, so a timestamp carrying a fifteenth digit, 202607191500000, parses its first fourteen cleanly and hands the stray 0 to the suffix reader, which sees a character that is not a sign and takes the no-offset path. Run through the parser, that value returns 15:00 UTC and the extra digit is discarded without comment. That is the harmless outcome for a trailing Z, which the same fall-through handles and which happens to mean UTC anyway, and a silent truncation for anything else. The Z case is only harmless because nothing in the shipping app sets an assumed timezone, which is a section of its own further down.

These seven are pinned as failures by test, and each of them names a real class of damage:

  • +2, a one-digit hour, too short to reach the switch
  • +02:0, a colon form with a digit missing
  • +0X30, a letter inside the digit run
  • +2400, twenty-four hours
  • +99:00, ninety-nine hours
  • -0270, seventy minutes
  • +, a sign and nothing else

Two guards produce most of those refusals. Minutes above 59 are rejected outright, and the total magnitude is capped at 14 hours, because the underlying date type refuses anything beyond that and, as the code comment puts it, "a guide claiming one is corrupt rather than exotic." The bound is inclusive, so +1400 resolves to one hour past midnight UTC and +1401 fails. The digit test also demands ASCII digits specifically and parses with no number styles enabled, so no leading sign, no thousands separator and no full-width digit sneaks through a lenient culture-aware parse.

Two of the DTD's three example timestamps are refused by our reader

The DTD does not only permit the fourteen-digit form. It says a date can be YYYYMMDDhhmmss or some initial substring of it, and it prints three examples: 200007281733 BST, 200209, and 19880523083000 +0300. Only the third survives our parser, resolving to 05:30 UTC. The second fails on the length floor, which refuses anything shorter than fourteen characters before a parse is attempted. The first clears that floor and then fails anyway, because the reader slices the first fourteen characters as the date core and 200007281733 B is not a date.

That narrowing is a choice we made and it is not the file's fault when it fires. A minute-precision timestamp has no seconds to slice, a year-and-month timestamp is not a program start in any useful sense, and a reader that accepted either would be inventing the missing fields. Refusing them is defensible. Presenting them as damage is not, and the last row of the walkthrough below is conformant XMLTV that we drop.

The DTD's first example carries the other half of this article's asymmetry in one parenthesis. 200007281733 BST is annotated there as (BST == +0100.), so the document that defines the format resolves the very abbreviation our reader declines to resolve.

Why +0100 BST is believed and a bare BST is not

There is an asymmetry here that looks like an oversight and is a decision. A suffix of +0100 BST resolves to plus one hour, because the number is present and the label is thrown away. A suffix of BST alone resolves to UTC, which is an hour wrong for every British summer date it appears on. Both are pinned by tests, which is to say both are on purpose.

Named zones are never resolved, and the comment gives the reason: "the abbreviations are ambiguous across regions." BST is British Summer Time, and it has also been written for Bangladesh Standard Time, five hours apart. CST is the collision that survives in the tz database itself, carried at once by Chicago, Havana and Shanghai. Resolving a name means picking one of them, which means guessing, and a guess that is right in one market and wrong in another is worse than a rule that is wrong the same way everywhere.

We took the knowably-wrong-by-a-fixed-rule answer over the guess, and it costs an hour on summer dates in any feed that labels its times this way instead of numbering them. The DTD annotating its own example makes that harder to defend than it looked before this article was written: the format's own documentation resolved one abbreviation, and the paragraph above is the whole of our argument for why we still do not.

That is a real limitation and we do not know how common the shape is, because a bare zone name is exactly the input that produces no error, no counter and no complaint until somebody compares the guide to a clock.

The diagnostics line counts drops without saying why

A timestamp that fails to parse does not throw. The attribute is never assigned, the start stays at zero, the row is refused at build time, and a counter increments. That counter surfaces in the source's guide settings dialog under "Last guide fetch", formatted as:

{0} channels · {1} programmes · {2} dropped · {3} ms

It is the only readout you get, and it is honest about the total and silent about the reason. Three unrelated conditions increment the same number: a program with no channel attribute, a program whose start timestamp failed, and a program that fell outside the retention window.

The window is where a large number usually comes from, and it is not one window. It always begins 24 hours behind the parse instant. It ends eight days ahead for a per-source guide, four days ahead when the uncompressed download exceeds 200 MB, and two days ahead for the household-wide store, which keeps less because it holds every source at once. A guide publishing three weeks of listings will therefore report a very large drop count on an entirely healthy import, and the identical file will report a much larger one once the household-wide store is switched on. A high number is a reason to look, not evidence of a time problem.

The uncomfortable part is that our loudest failure mode is a single integer in a dialog nobody opens unless something already looks wrong.

A bad stop costs you three hours, not one program

An unreadable offset on the start timestamp drops the row. An unreadable offset on the stop timestamp does not.

When the start parses and the stop does not, the stop stays at zero, which fails the "stop is later than start" test, and the row is given a default duration of three hours. A separate clamp limits any program to six hours; it stays inert here, since three is under six, and bites only on rows whose stop does parse and lands a long way out. Neither the default nor the clamp increments the drop counter. So a file whose stop attributes are malformed in a way its start attributes are not produces a guide with correct start times, three-hour blocks everywhere, overlapping rows, and a clean import summary. That is a common shape behind reports of a guide where the times look right but nothing lines up.

Six programs go in and four come out

Here is a fragment built to exercise every path at once. It was executed against the shipped parser rather than traced by hand. Every channel id uses a reserved example domain and every title is invented, and you should assume the retention window covers these dates, otherwise retention removes the argument before the offsets get a turn.

<?xml version="1.0" encoding="UTF-8"?>
<tv>
  <channel id="one.example.com"><display-name>Example One</display-name></channel>
  <programme start="20260719150000 +0200" stop="20260719160000 +0200" channel="one.example.com">
    <title>Clean offset</title>
  </programme>
  <programme start="20260719160000 +02:0" stop="20260719170000 +02:0" channel="one.example.com">
    <title>Broken on both ends</title>
  </programme>
  <programme start="20260719170000 +02:00" stop="20260719180000 +02:0" channel="one.example.com">
    <title>Broken on the stop only</title>
  </programme>
  <programme start="20260719180000 BST" stop="20260719190000 BST" channel="one.example.com">
    <title>Zone name, no number</title>
  </programme>
  <programme start="20260719190000 +0100 BST" stop="20260719200000 +0100 BST" channel="one.example.com">
    <title>Zone name after a number</title>
  </programme>
  <programme start="20260719" stop="20260720" channel="one.example.com">
    <title>Date only</title>
  </programme>
</tv>

Row one is the happy path: 15:00 at plus two hours is 13:00 UTC, and it runs to 14:00 UTC.

Row two has +02:0 on both attributes. The suffix begins with a sign, so it must read as an offset; the digit run is four characters and the last two are not both digits, so it fails. The start is never assigned and the row is refused, which takes the drop counter to one.

Row three has a clean start and the same broken stop. The start resolves to 15:00 UTC. The stop does not resolve, so the three-hour default applies and the row occupies 15:00 to 18:00 UTC instead of one hour. Nothing is counted. This row is the reason the drop counter is not a health metric.

Row four is the one-hour case in its purest form. The suffix is BST, which does not begin with a sign, so the reader takes the no-offset path and applies the assumed timezone, which is UTC. The grabber meant 17:00 UTC. The guide shows 18:00 UTC. No counter moves.

Row five carries the same label after a real number, and resolves correctly to 18:00 UTC. Rows four and five therefore both start at 18:00 UTC on one channel and only one of them meant to, which is what a file with two grabbers behind it looks like from the inside.

Row six is eight characters, under the fourteen-character floor, so both attributes fail and the drop counter goes to two. It is also the row the DTD would call legal, an initial substring of a full timestamp, so half of the counted damage in this fragment is our narrowing rather than the file's.

Four rows reached the store, and two of the four are wrong in a way nothing reports: one starts an hour late, and one runs for three times its real length. The counter reports the other two and gives no reason for either.

The offset is resolved per instant, not once per file

The date core is parsed with no assumption flags set at all, which returns a value whose kind is unspecified, and that is deliberate. The reader then pairs it with an offset explicitly. The alternative, handing an unspecified value to a constructor that fills in the blank, makes .NET apply the host machine's own offset, so the same file would produce a different schedule on a device in Paris and a device in Tunis, and the store would stop being portable between two devices in one household.

On the no-offset path the assumed zone's offset is looked up for that particular instant rather than once for the whole file. Across a spring transition in a western European zone, two timestamps two and a half hours apart on the local clock come out ninety minutes apart in UTC, which is correct and is what a single file-level offset would get wrong for every row after the change. The two local times a transition makes unusable, the hour that happens twice and the hour that never happens, both resolve to the standard offset, and a re-parse returns the same value, so one file cannot render as two different guides on two consecutive imports.

There is an honest gap in that machinery. The assumed-timezone parameter exists, both of the paths that ingest a guide on the device pass it, and both pass null. Nothing in the shipping app ever sets it, and a test pins that. A guide that writes bare local wall-clock times with no suffix is read as UTC, which is the DTD's documented default, and there is no per-source control that changes how it is parsed. Everything in the transition paragraph above is machinery no shipped code path currently reaches.

The hour correction is whole hours, and two places switch it off

There is a correction, and it works at a different layer. Each source carries a time shift in whole hours, from minus 48 to plus 48, editable in that same guide settings dialog under "Time shift (whole hours)" with the field labeled "Hours to add". It is applied when listings are read out of the store, not when they go in, so changing it does not require re-downloading anything and does not rewrite what was stored.

Two things it deliberately will not do. It is whole hours only, so it cannot correct a half-hour zone, and a source in a +05:30 region that has been read as UTC cannot be brought back with it. And it is forced to zero in two cases. The first is the household-wide guide store, which is normalized to UTC on ingest and has nothing left for a shift to correct. The second is any channel belonging to an aggregated source, where the channel is backed by several members at once and the right offset depends on which member is serving it. A single number would be wrong for all but one of them, so the control is not offered rather than being offered and lying.

That last decision is the one I would revisit first if the evidence changed. It is defensible, and it also means the escape hatch is missing exactly where a household has the most sources and the most chances of one of them being wrong.

Our Windows and Xbox apps do all of this parsing locally, on a guide address you already have. The diagnostics line and the shift control sit in the same dialog for a reason: the first number tells you whether the file is being read at all, and the second is what you reach for once you know that it is.

If you are writing your own reader, the rule that saves you is the one that took us the longest to learn. A suffix that starts with a sign has told you it is a number. Read it or fail the row, but do not fall through to a default, because a default is applied uniformly and confidently to every timestamp in the file, and uniform confident wrongness is the one failure nobody reports.

What this article measured28 claims, each with the evidence behind it
ClaimEvidenceCounted
The XMLTV document type definition says a timestamp carrying no explicit timezone is to be read as UTC.xmltv.dtd, date field documentation: you can also append a timezone to the end; if no explicit timezone is given, UTC is assumed.SpecificationNot applicable
The XMLTV DTD writes its own example offset in the four-digit basic shape, as +0300.xmltv.dtd, date field documentation, example 19880523083000 +0300SpecificationNot applicable
The XMLTV DTD permits a date to be YYYYMMDDhhmmss or some initial substring of it, and prints 200007281733 BST, 200209 and 19880523083000 +0300 as its three examples.xmltv.dtd, date field documentation, including the annotation (BST == +0100.) on the first exampleSpecificationNot applicable
ISO 8601 writes a UTC offset in three shapes: basic (+0200), extended (+02:00), and hours only (+02).ISO 8601-1, time offset from UTCSpecificationNot applicable
A .NET DateTimeOffset rejects an offset whose magnitude exceeds 14 hours, and accepts exactly 14 hours..NET DateTimeOffset constructor, offset argument range -14:00 to +14:00 inclusiveSpecificationNot applicable
Constructing a DateTimeOffset from a DateTime whose Kind is Unspecified applies the host machine's local offset..NET DateTimeOffset(DateTime) constructor remarks, which treat an Unspecified Kind as local timeSpecificationNot applicable
Zone abbreviations are not unique: CST is carried by America/Chicago, America/Havana and Asia/Shanghai at once, and BST is British Summer Time and has also been written for Bangladesh Standard Time, five hours apart.IANA time zone database, theory.html on the ambiguity of zone abbreviations, plus the CST abbreviation on America/Chicago, America/Havana and Asia/ShanghaiSpecificationNot applicable
A timestamp suffix beginning with + or - must parse as a numeric offset, or the whole timestamp fails. Falling through to the assumed timezone is the shipped defect this rule replaced.n = 2Aug 22, 2026
Only digit runs of length 2, 4, or 5 with a colon at index 2 are accepted, so +02, +0200 and +02:00 all resolve; every other length falls out of the default branch.n = 3Aug 22, 2026
Seven offset shapes are pinned as parse failures by test: +2, +02:0, +0X30, +2400, +99:00, -0270, and a bare +.n = 7Aug 22, 2026
Minutes above 59 are rejected and total magnitude is capped at 14 hours inclusive, so +1400 resolves and +1401 does not; the two-digit reader demands ASCII digits and parses with NumberStyles.None.n = 2Aug 22, 2026
A zone label after a real offset is discarded at the first space, so +0100 BST resolves to +01:00 and a 15:00 local time yields 14:00 UTC.n = 1Aug 22, 2026
A bare alphabetic zone name never reaches the offset reader and takes the assumed-timezone path, so 20260719150000 BST and 20260719150000 UTC both resolve to 15:00 UTC.n = 2Aug 22, 2026
Two of the three example timestamps printed by the XMLTV DTD are refused by our reader: 200007281733 BST and 200209 both fail, and only 19880523083000 +0300 parses, to 05:30 UTC.n = 3Aug 22, 2026
A timestamp shorter than 14 characters is refused before any parse is attempted, so a date-only value fails.n = 1Aug 22, 2026
The date core is exactly the first 14 characters, so a stray 15th digit lands in the suffix, fails the sign test, and is discarded silently.n = 1Aug 22, 2026
The hard-error rule keys on the first character of the suffix, so a label-first form such as GMT+0200 takes the assumed-timezone path and is read as UTC with the offset ignored.n = 1Aug 22, 2026
The same drop counter increments for a missing channel attribute, an unparseable start, and a programme outside the retention window.n = 3Aug 22, 2026
The retention window always starts 24 hours behind the parse instant and ends eight days ahead on the per-source path, four days when the uncompressed download exceeds 200 MB, and two days for the household-wide store.n = 3Aug 22, 2026
A programme with a valid start and an unparseable stop is kept with a three-hour default duration and is not counted as dropped; the separate six-hour maximum does not bite on it.n = 1Aug 22, 2026
The last-fetch readout is formatted as channels, programmes, dropped and milliseconds, under the heading Last guide fetch.n = 1Aug 22, 2026
The per-source correction is whole hours from -48 to +48, labeled Time shift (whole hours) with the field Hours to add, applied at read time rather than at ingest.n = 1Aug 22, 2026
The correction is forced to zero when the household-wide store is active and when the channel belongs to an aggregated source.n = 2Aug 22, 2026
Both device ingest paths construct the parse pipeline with assumeTimezone null, so a bare timestamp is read as UTC and the host timezone never enters.n = 2Aug 22, 2026
On the assumed-timezone path the offset is resolved per instant, so two local times two and a half hours apart across a spring transition come out ninety minutes apart in UTC.n = 2Aug 22, 2026
The repeated local hour and the nonexistent local hour both resolve to the standard offset, and a re-parse returns the same value.n = 3Aug 22, 2026
The hand-built fragment in this article yields 1 channel, 4 stored rows and 2 drops; one stored row starts an hour late and one runs for three hours instead of one.n = 6Aug 22, 2026
In the larger of the two playlists in our M3U corpus, 527 of 1,895 rows end with no usable channel id, so a guide row has nothing dependable to attach itself to.n = 1895Aug 22, 2026