OneBug All write-ups

Go ·

Go time.Format YYYY-MM-DD not working (use 2006-01-02)

The invoice header printed the literal string YYYY-MM-DD. Go layouts use the reference date 2006-01-02, not letters - the fix, and why go vet says nothing.

Test yourself before reading - the write-up below gives it away.

Spot the bug Go
1
package main
2
 
3
import (
4
	"fmt"
5
	"time"
6
)
7
 
8
// Print the invoice dates for the header block.
9
func main() {
10
	issued := time.Date(2026, time.August, 6, 15, 4, 5, 0, time.UTC)
11
	due := issued.AddDate(0, 0, 14)
12
	fmt.Println("issued:", issued.Format("YYYY-MM-DD"))
13
	fmt.Println("due:", due.Format("2006-01-02"))
14
	// expected: issued: 2026-08-06  due: 2026-08-20
15
	// actual:   issued: YYYY-MM-DD  due: 2026-08-20  (!)
16
}

In the invoice PR, the reviewer's only comment was a nit: don't hardcode the date format, spell it out so the next person can read it. The author took the note, rewrote the issued-date line in plain letters, and pushed a fixup commit. CI went green, because nothing in the test suite diffs the printed header against a known string, and a teammate approved on the second pass. Two release cycles later, a customer emailed support asking why their invoice was dated YYYY-MM-DD.

Here is the line that shipped, printing both dates on an invoice header:

package main

import (
	"fmt"
	"time"
)

// Print the invoice dates for the header block.
func main() {
	issued := time.Date(2026, time.August, 6, 15, 4, 5, 0, time.UTC)
	due := issued.AddDate(0, 0, 14)
	fmt.Println("issued:", issued.Format("YYYY-MM-DD"))
	fmt.Println("due:", due.Format("2006-01-02"))
	// expected: issued: 2026-08-06  due: 2026-08-20
	// actual:   issued: YYYY-MM-DD  due: 2026-08-20  (!)
}

Why does Go's time.Format print YYYY-MM-DD literally instead of the date?

Go's time.Format and time.Parse do not read placeholder letters like YYYY or MM. A layout is written as an example of the reference instant, Mon Jan 2 15:04:05 MST 2006, so issued.Format("YYYY-MM-DD") matches none of that instant's components and gets echoed back untouched. The fix is issued.Format("2006-01-02"), or the time.DateOnly constant added in Go 1.20, which holds that same string.

What Go's own docs mean by "the reference time"

The package doc states the mechanism directly, and it says so in its own words:

"The reference time used in the layouts is the specific time stamp: 01/02 03:04:05PM '06 -0700 (January 2, 15:04:05, 2006, in time zone seven hours west of GMT)." - pkg.go.dev/time

Every other language's strftime-style format is a grammar of tokens: %Y means "the year," %m means "the month." Go skips the grammar and shows you an example instead: write the reference date the way you want your date to look, and Format performs the same transformation on the real value. The mnemonic is the numbers in order, 1 2 3 4 5 6 7, or 01/02 03:04:05PM '06 -0700. That is why line 12's decoy, due.Format("2006-01-02"), is not a hardcoded date at all: 2006-01-02 is year, month, day of the reference instant, spelled the way a year-month-day output should look. It reads like a magic string because it is one, and it happens to be the correct one.

Nothing in that pipeline treats an unrecognized layout as an error. Format returns a plain string, never an error; the parser walks the layout looking for known substrings (2006, 01, 02, Jan, Mon, MST, and a short list of others) and copies anything it does not recognize straight into the output. YYYY-MM-DD matches none of them, so all ten characters pass through unchanged. A Go engineer filed exactly this complaint in 2019, expecting Format to reject a nonsense layout outright; the response from the standard library was that this has always been the contract, and the issue was closed without a change (golang/go#34997). The behavior is not a bug in the language. It is a design the language has held onto since 1.0, and that is what lets it survive so many code reviews.

The month-end close where a date field wasn't a date

An invoicing service prints this header into a customer-facing PDF and, separately, writes the same issued string into a CSV export finance pulls every month for reconciliation. For most of a quarter nobody notices, because the PDF is something a customer opens once and the CSV is something finance filters by amount, not by eyeballing every date cell. The month this shipped, finance's spreadsheet sorts the export by date to match it against bank deposits, and one row refuses to sort: its date column reads YYYY-MM-DD, a string that collates before every real date and lands at the top of the sheet. The analyst assumes it is a stray header row copied in twice, deletes it, and closes the books one invoice short. The actual invoice was fine. Its date field was never a date.

The fix, and the wrong fix that muscle memory reaches for

issued.Format("2006-01-02")   // "2026-08-06"
issued.Format(time.DateOnly)  // same string, since Go 1.20

The tempting wrong fix, for anyone arriving from Python or C, is to reach for strftime-style specifiers: issued.Format("%Y-%m-%d"). Go's layout parser does not treat % as a trigger either, so that call fails the exact same way: it prints %Y-%m-%d back at you, letters and percent signs intact, a different wrong string standing in for the same missing date. There is no partial credit for using the right idea in the wrong language's syntax.

Grep your codebase for a letter-spelled date layout

go vet will not catch this. The one analyzer that comes close, timeformat in golang.org/x/tools (the same check gopls runs as you type), flags a single specific mistake: 2006-02-01, the day and month swapped inside an otherwise-correct layout. It has nothing to say about a layout built from letters instead of the reference date, because from the parser's point of view "YYYY-MM-DD" is not malformed at all: it is a valid layout with no recognized fields in it. Grep for the shape by hand:

grep -rnE '\.(Format|Parse)\("[^"]*(YYYY|MM|DD|yyyy|mm|dd|%Y|%m|%d)[^"]*"' --include=*.go .

Every hit is a layout that was never checked against the reference time. Read each one back as a date (2006, 01, 02) or replace it with the matching time.DateOnly, time.DateTime, or time.RFC3339 constant and delete the guesswork.

Related bugs

The line that looks handwritten and dangerous, then turns out fine, is a repeat offender in Go: the same shape drives a := inside an if block that quietly declares a second variable, and a nil pointer that stops being nil the moment it crosses into an interface. More on the Go bug hub, everything else at bug write-ups.

The fixup commit that broke the header is still in the PR history, one diff line: three letters swapped for four digits, and back.