How to Handle JSON Dates and Times in Go

Handle JSON dates in Go tar archives by using Header time fields and setting Format to PAX or GNU for precision.

The midnight deadline problem

You ship a JSON payload to your Go backend. The frontend sends a creation date as "2023-11-05T14:30:00Z". Your struct has a time.Time field. You call json.Unmarshal. It works perfectly. Two days later, a legacy service sends "Nov 5, 2023". The unmarshaler fails. You spend an hour converting strings, losing type safety, and wondering why Go refuses to be flexible.

Go treats dates like strict contracts. The standard library provides time.Time as the canonical representation of a moment in time. The encoding/json package expects one specific format. It does not guess. It does not try multiple layouts. It checks the format, matches it, or rejects it. This design keeps serialization predictable, fast, and free of hidden timezone bugs.

How Go reads dates

Go's date handling revolves around a single type: time.Time. It stores an instant in time alongside a timezone location. When you marshal a time.Time to JSON, Go converts it to a string. When you unmarshal JSON back into a time.Time, Go parses that string and reconstructs the instant.

The default format is RFC3339. It looks like "2006-01-02T15:04:05Z07:00". That specific string is not a coincidence. Go uses the reference time Mon Jan 2 15:04:05 MST 2006 as the template for all date and time layouts. The numbers in the template represent the exact components of the reference time. If you swap them around, you change the expected format.

Think of the JSON parser as a customs officer with a single stamp. It only accepts packages labeled with that exact stamp. If the label differs by even a character, the package gets rejected. This strictness prevents silent data corruption. You always know exactly what format your code expects.

The default path: RFC3339

Here is the simplest way to handle dates in JSON. Define a struct with a time.Time field. Marshal it. Unmarshal it. The standard library handles the rest.

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Event struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

func main() {
	// Zero value of time.Time is 0001-01-01, which marshals to a valid RFC3339 string.
	e := Event{ID: 1, Name: "launch", CreatedAt: time.Now()}
	
	// Marshal converts time.Time to RFC3339 automatically.
	data, err := json.Marshal(e)
	if err != nil {
		fmt.Println("marshal failed:", err)
		return
	}
	fmt.Println(string(data))
	
	// Unmarshal expects RFC3339. It reconstructs the time.Time value.
	var decoded Event
	err = json.Unmarshal(data, &decoded)
	if err != nil {
		fmt.Println("unmarshal failed:", err)
		return
	}
	fmt.Println(decoded.CreatedAt)
}

The code above works because time.Time implements the json.Marshaler and json.Unmarshaler interfaces. When encoding/json encounters a time.Time field, it delegates to those methods. The marshaler calls time.MarshalJSON(), which formats the instant using time.RFC3339Nano. The unmarshaler calls time.UnmarshalJSON(), which parses the string back into a time.Time.

If your JSON lacks a timezone offset, Go assumes UTC. This is a deliberate design choice. It prevents ambiguous local times from causing off-by-one-hour bugs in distributed systems. You get a consistent baseline. You can convert to local time later if the UI requires it.

Default formatting keeps your code clean. Trust the standard library for standard formats.

What happens under the hood

When you unmarshal JSON into a struct, the encoding/json package walks the struct fields. It checks for struct tags. It matches JSON keys to Go fields. When it hits a time.Time, it does not use reflection to guess the layout. It calls the precompiled UnmarshalJSON method that time.Time provides.

That method expects a byte slice. It trims whitespace. It calls time.Parse(time.RFC3339, string(b)). If the string matches the layout, you get a time.Time. If it does not, you get an error. The error message tells you exactly which part of the string failed to match the layout.

This mechanism is fast because it avoids runtime layout detection. It is safe because it rejects malformed dates instead of silently shifting them. The tradeoff is that you must control the input format or write a custom parser.

Go favors explicit over implicit. Write the parser that matches your data.

Handling non-standard formats

Real-world APIs rarely follow RFC3339 perfectly. Databases ship "2023-11-05 14:30:00". Frontends send "11/05/2023". Legacy systems use Unix timestamps. You cannot change the upstream format. You must adapt your Go code.

The cleanest approach is to create a custom type that wraps time.Time. Implement UnmarshalJSON on that type. Keep the standard time.Time behavior for everything else. This isolates the parsing logic and leaves the rest of your codebase untouched.

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

// CustomDate wraps time.Time to handle non-RFC3339 JSON formats.
type CustomDate struct {
	time.Time
}

// UnmarshalJSON implements json.Unmarshaler for CustomDate.
// It parses "2006-01-02" format and falls back to RFC3339.
func (cd *CustomDate) UnmarshalJSON(b []byte) error {
	// Trim quotes from the raw JSON string.
	s := string(b)
	if len(s) >= 2 {
		s = s[1 : len(s)-1]
	}
	
	// Try the custom date-only format first.
	t, err := time.Parse("2006-01-02", s)
	if err != nil {
		// Fall back to standard RFC3339 parsing.
		t, err = time.Parse(time.RFC3339, s)
		if err != nil {
			return fmt.Errorf("invalid date format: %w", err)
		}
	}
	
	// Copy the parsed time into the embedded struct.
	cd.Time = t
	return nil
}

type Report struct {
	Title string     `json:"title"`
	Date  CustomDate `json:"date"`
}

func main() {
	// Simulate JSON with a non-standard date format.
	jsonData := `{"title": "Q3 Summary", "date": "2023-11-05"}`
	
	var r Report
	err := json.Unmarshal([]byte(jsonData), &r)
	if err != nil {
		fmt.Println("failed:", err)
		return
	}
	
	// The embedded time.Time gives you full access to standard methods.
	fmt.Println(r.Date.Format("January 2, 2006"))
}

The custom type approach keeps your parsing logic contained. You get a time.Time everywhere else in your code. You can call .Year(), .Add(), or .Unix() without casting. The receiver name cd follows Go convention: one or two letters matching the type. The error wrapping uses %w so callers can inspect the root cause with errors.Is.

Custom unmarshalers are cheap to write. Keep them focused on one format.

Pitfalls and runtime surprises

Date handling in Go trips developers up in predictable ways. The compiler catches type mismatches, but runtime parsing errors only appear when bad data flows through.

If you forget to trim quotes in a custom unmarshaler, the parser fails with parsing time "\"2023-11-05\"" as "2006-01-02": cannot parse "\"2023-11-05\"" as "2006". The extra quotes shift every character. Always strip them before calling time.Parse.

Timezone loss is another common issue. When you unmarshal "2023-11-05T14:30:00" without an offset, Go assumes UTC. If your database expects local time, you will see a silent shift. Always verify the timezone after unmarshaling. Call .Location() on the resulting time.Time to confirm it matches your expectations.

The omitempty struct tag behaves unexpectedly with dates. The zero value of time.Time is 0001-01-01 00:00:00 +0000 UTC. It is not an empty string. If you tag a field with omitempty, Go will still marshal it because the value is not technically empty. Use a pointer *time.Time if you want true omission for null dates.

Goroutine leaks rarely happen with date parsing, but they do happen when you spawn background workers to batch-process JSON payloads. Always pass a context.Context as the first parameter to long-running handlers. Respect cancellation. Clean up channels.

The worst date bug is the one that silently shifts a timestamp by one hour. Validate timezones early. Log the parsed location.

Choosing your date strategy

Picking the right approach depends on your data source and your downstream consumers. Match the tool to the constraint.

Use time.Time with default encoding/json when your API contract supports RFC3339 and you want zero boilerplate. Use a custom type with UnmarshalJSON when you must accept legacy or third-party date formats without breaking the rest of your codebase. Use string fields with explicit validation when you are passing dates through to a database without parsing them. Use int64 Unix timestamps when you need sub-second precision without timezone ambiguity. Use *time.Time with omitempty when your schema requires true null values instead of epoch zeros.

Stick to time.Time unless you have a concrete reason to deviate. The standard library handles the hard parts. Write custom parsers only when the data forces you to.

Where to go next