Almost all examples of time resolution in Go take the full time including the date, and the official example is no exception. But few people have used the following code as an example to explain the behavior until the day the user makes a mistake.

Background of the problem

Trying to get the user to enter a readable time as input from the terminal, but I find that I am parsing the input incorrectly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	fmt.Println(now)
	layout, input := `15:04`, `02:27`
	t, _ := time.ParseInLocation(layout, input, now.Location())
	fmt.Println(t)
}

A very simple piece of code (ignoring the error handling) parses the partial time of an input at the current location.

1
2
3
$ go run main.go
2021-12-13 02:27:40.930331 +0800 CST m=+0.000177157
0000-01-01 02:27:00 +0805 LMT

The hours and minutes are correctly resolved, and the year, month, day, seconds and nanoseconds are also correct defaults. Where is it incorrect? The time zone.

Isn’t it strange that one is CST (China Standard Time, +0800) and the other LMT (Local Mean Time?, +0805). I obviously took the now Location and parse it, but why is the time zone/location different when I parse it? It’s very strange!

Find the answer from the source code

Call stack: Time.StringTime.FormatTime.AppendFormatTime.locabsLocation.lookup .

1
2
3
4
5
// AppendFormat is like Format but appends the textual
// representation to b and returns the extended buffer.
func (t Time) AppendFormat(b []byte, layout string) []byte {
	var (
		name, offset, abs = t.locabs()

This locabs is the point I want to focus on. Where name is the name of the time zone and offset is the offset of the time zone. Here’s the problem: why does it count as “wrong”?

locabs calls lookup and here is the source code (with deletions).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// lookup returns information about the time zone in use at an
// instant in time expressed as seconds since January 1, 1970 00:00:00 UTC.
//
// The returned information gives the name of the zone (such as "CET"),
// the start and end times bracketing sec when that zone is in effect,
// the offset in seconds east of UTC (such as -5*60*60), and whether
// the daylight savings is being observed at that time.
func (l *Location) lookup(sec int64) (name string, offset int, start, end int64) {
	l = l.get()

	// ... cut off ...

	if len(l.tx) == 0 || sec < l.tx[0].when {
		zone := &l.zone[l.lookupFirstZone()]
		name = zone.name
		offset = zone.offset
		start = alpha
		if len(l.tx) > 0 {
			end = l.tx[0].when
		} else {
			end = omega
		}
		return
	}

	// ... cut off ...

	// Binary search for entry with largest time <= sec.
	// Not using sort.Search to avoid dependencies.
	tx := l.tx
	end = omega
	lo := 0
	hi := len(tx)
	for hi-lo > 1 {
		m := lo + (hi-lo)/2
		lim := tx[m].when
		if sec < lim {
			end = lim
			hi = m
		} else {
			lo = m
		}
	}
	zone := &l.zone[tx[lo].index]
	name = zone.name
	offset = zone.offset
	start = tx[lo].when

	// ... cut off ...

	return
}

Before interpreting this code, let’s look at the definition of Location.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// A Location maps time instants to the zone in use at that time.
// Typically, the Location represents the collection of time offsets
// in use in a geographical area. For many Locations the time offset varies
// depending on whether daylight savings time is in use at the time instant.
type Location struct {
	name string
	zone []zone
	tx   []zoneTrans

	// ... cut off ...
}

// A zone represents a single time zone such as CET.
type zone struct {
	name   string // abbreviated name, "CET"
	offset int    // seconds east of UTC
	isDST  bool   // is this zone Daylight Savings Time?
}

// A zoneTrans represents a single time zone transition.
type zoneTrans struct {
	when         int64 // transition time, in seconds since 1970 GMT
	index        uint8 // the index of the zone that goes into effect at that time
	isstd, isutc bool  // ignored - no idea what these mean
}

From the comments and the definition, the problem starts to become clear: a Location contains multiple Zones and multiple Transitions. So what is lookup doing? It’s looking for which time zone of this location to use for the input parameter sec (Unix timestamp).

For me, being a bit geographically and historically illiterate, I didn’t know much about this. So I went and looked up some information (Visipedia - China Time Zones).

China is a vast country and can be divided into five time zones: East 5, East 6, East 7, East 8 and East 9 according to the international standard of time zone division. During the mainland period of the Republic of China, the country’s time zones were divided into Kunlun time zone, Hui-Zang time zone (later renamed Xin-Zang time zone), Long-Shu time zone, Zhongyuan time zone, and Changbai time zone according to international standards. After the founding of the People’s Republic of China in 1949, the whole of mainland China was unified into the eastern eight zones (UTC+8), and Beijing time was adopted as the only standard time in the country.

Simply put, the time zones of the same geographical location are subject to change throughout history (fall of countries, creation of countries, change of regime, etc.). This information is maintained and collated in the Time Zone Information Database.

Going back to the definition of a location, zone records all the time zones that occur at that location, and tx records when a time zone changes to another time zone.

It is now clear what lookup is doing.

Attachment: Zone content of the Location in my example.

1
2
3
4
5
6
7
8
9
- Name:LMT
  Offset:29143
  IsDST:false
- Name:CDT
  Offset:32400
  IsDST:true
- Name:CST
  Offset:28800
  IsDST:false

CST is the +8 (86060=28800) time zone we are currently using.

Tx I won’t attach, there are as many as 29.

Answers

I made the user’s input contain only hours and minutes, so the default year is 0000, which is converted to a Unix timestamp (a negative number compared to 1970) and the corresponding Zone is indeed LMT by Tx.

So how do we get the input value to parse correctly? Use FixedZone.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// FixedZone returns a Location that always uses
// the given zone name and offset (seconds east of UTC).
func FixedZone(name string, offset int) *Location {
	l := &Location{
		name:       name,
		zone:       []zone{{name, offset, false}},
		tx:         []zoneTrans{{alpha, 0, false, false}},
		cacheStart: alpha,
		cacheEnd:   omega,
	}
	l.cacheZone = &l.zone[0]
	return l
}

This Zone constitutes a Location with only one Zone and one Transition.

Let’s modify the sample code again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	fmt.Println(now)
	layout, input := `15:04`, `02:31`
	loc := time.FixedZone(now.Zone())
	t, _ := time.ParseInLocation(layout, input, loc)
	fmt.Println(t)
}

The desired answer can now be obtained.

1
2
3
$ go run main.go
2021-12-13 02:31:00.516906 +0800 CST m=+0.000260175
0000-01-01 02:31:00 +0800 CST