JavaRush /Courses /Go SELF /time.Time in JSON: format and compatibility

time.Time in JSON: format and compatibility

Go SELF
Level 45, Lesson 2
Available

1. encoding/json: time.Time defaults and RFC3339

In real programs, date and time are like salt in food: it seems like a small thing, but if you overdo it or forget it, it will hurt. JSON itself does not have a “date/time” type: it has a string, a number, null, and so on. So “how to transmit time” is always an agreement (a contract) between two sides.

The most common cause of bugs here sounds almost comical: one developer thought that a “date string” is "16.01.2026 10:00", another expected "2026-01-16T10:00:00Z", and a third one sent a number of seconds since 1970. And all of them are right in their own way—until you explicitly pick one format and lock it in as a contract.

So that we do not turn the project into a long-running series called “How I Spent My Summer Debugging Time Zones,” we will follow a simple rule: time in JSON is a string in RFC3339 format (usually UTC). This is the most compatible option with almost everything—from JavaScript to Python and Java.

How encoding/json encodes and decodes time.Time

Now for a pleasant moment: in Go you usually do not need to invent a date format for JSON manually. The time.Time type already knows how to serialize and deserialize via encoding/json “the proper way”: in JSON it will be a string.

Let us look at a minimal example: we create a struct, set a time value, run Marshal, and print the result. Note: in JSON the time looks like a quoted string.

package main

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

type Event struct {
	Name string    `json:"name"`
	At   time.Time `json:"at"`
}

func main() {
	e := Event{
		Name: "deploy",
		At:   time.Date(2026, 1, 16, 12, 30, 0, 0, time.UTC),
	}

	b, _ := json.Marshal(e)
	fmt.Println(string(b)) // {"name":"deploy","at":"2026-01-16T12:30:00Z"}
}

From a “compatibility” standpoint, what matters is: this is not a “local date,” not “whatever accounting is used to,” but a format that most systems understand without additional explanations.

Now the reverse operation: we read JSON and get time.Time back.

package main

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

type Event struct {
	At time.Time `json:"at"`
}

func main() {
	data := []byte(`{"at":"2026-01-16T12:30:00Z"}`)

	var e Event
	_ = json.Unmarshal(data, &e)

	fmt.Println(e.At.UTC().Format(time.RFC3339)) // 2026-01-16T12:30:00Z
}

A useful habit here: when you want to “normalize” time for output, convert to UTC and format via time.RFC3339. This makes logs and files much more predictable.

RFC3339: what it is and why almost everyone likes it

RFC3339 is the “diplomatic language” for date/time. It looks a bit “mechanical,” but almost everywhere it can be parsed without guessing. The key point is that it always explicitly states which time zone/offset is used: either Z (UTC), or +03:00, or -05:00.

Conceptually, an RFC3339 string looks like this:

YYYY-MM-DDTHH:MM:SSZ
YYYY-MM-DDTHH:MM:SS+03:00

Mini table of examples:

Example string Meaning
2026-01-16T12:30:00Z
time in UTC
2026-01-16T15:30:00+03:00
the same instant, but written with a +3 hour offset
2026-01-16T12:30:00.123Z
the same, but with fractional seconds

An important nuance: these are all the same “instant”, just written differently. That is exactly why storing time in UTC is so convenient: fewer chances that someone will accidentally “add an hour” and break half the system.

3. UTC and local time: why time.Now() is sometimes not enough

When you call time.Now(), you get the current time with your machine’s local time zone (or the container’s, or the server’s). If the program runs “on one computer,” that may be tolerable. But as soon as you have different environments (your laptop, a server, CI), “local time” starts working against you.

That is why the practical contract for storage and data exchange is usually: store in UTC. Not because UTC is “better,” but because it is a universal coordinate system.

Example: we create a small nowUTC() helper and use it in models:

package main

import "time"

func nowUTC() time.Time {
	return time.Now().UTC()
}

And this is how we create a time value to write into JSON:

package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.Now().UTC()
	fmt.Println(t.Format(time.RFC3339)) // for example: 2026-01-16T19:05:10Z
}

One more nuance that is useful to know at the “do not panic if you see it” level: in Go, time.Time (starting with Go 1.9) can store monotonic time for correct duration measurements (for example, time.Since(start) is more robust against system clock changes). This is an internal detail, and it is not preserved during serialization—in JSON you only get the “wall clock” time. If you see the words “monotonic time,” do not worry; this is normal mechanics of the time package.

4. Optional dates: *time.Time, null, missing fields, and omitempty

In tasks (and in life in general), time is often optional. For example, a task has CreatedAt (always present), but DoneAt appears only when the task is completed. If we try to store DoneAt as a regular time.Time, we run into a question: what does 0001-01-01T00:00:00Z mean? Is it “the task was done in year one” or “there is no value”? (spoiler: yes, it sounds suspicious).

In Go, the clearest way to express “there may be no value” is a pointer: *time.Time. Then:

  • nil means “no date,”
  • a non-nil value means “there is a date.”

Example task struct with optional time:

package main

import "time"

type Task struct {
	Title  string     `json:"title"`
	DoneAt *time.Time `json:"done_at,omitempty"`
}

Notice omitempty: if DoneAt == nil, the field will not be included in JSON during Marshal.

Let us see what we get in practice:

package main

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

type Task struct {
	Title  string     `json:"title"`
	DoneAt *time.Time `json:"done_at,omitempty"`
}

func main() {
	t1 := Task{Title: "learn json"} // DoneAt == nil
	b1, _ := json.Marshal(t1)
	fmt.Println(string(b1)) // {"title":"learn json"}

	now := time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC)
	t2 := Task{Title: "ship feature", DoneAt: &now}
	b2, _ := json.Marshal(t2)
	fmt.Println(string(b2)) // {"title":"ship feature","done_at":"2026-01-16T12:00:00Z"}
}

Now the important point about contract compatibility: “the field is missing” and “the field exists but is null” are two different forms. Sometimes an API requires one, sometimes the other. In our basic course we pick the simplest and most common option: if there is no value, there is no field (via omitempty).

And what happens on read?

  • if the field is absent, DoneAt remains nil;
  • if the field exists and equals null, DoneAt also becomes nil;
  • if the field exists and the string is valid, DoneAt becomes a non-nil pointer;
  • if the field exists but the string is invalid, you get an Unmarshal error.

5. Mini ToDo: created_at and done_at in JSON

Now let us neatly “weave” time into our learning app that works with tasks. We will not build a complex architecture or discuss DTO vs domain right now; we will just add two fields and learn how to write/read them in JSON.

First, define Task so that it serializes well:

package main

import "time"

type Task struct {
	ID        int        `json:"id"`
	Title     string     `json:"title"`
	CreatedAt time.Time  `json:"created_at"`
	DoneAt    *time.Time `json:"done_at,omitempty"`
}

Now helper functions: “create a task” and “mark as done.” Here we intentionally use UTC so storage is stable.

package main

import "time"

func NewTask(id int, title string) Task {
	return Task{
		ID:        id,
		Title:     title,
		CreatedAt: time.Now().UTC(),
	}
}

func MarkDone(t *Task) {
	now := time.Now().UTC()
	t.DoneAt = &now
}

Note: MarkDone accepts *Task because it modifies the task “in place.” We already discussed pointers earlier, so this should look logical: “I want to change a struct—pass a pointer.”

Serialize a list of tasks to JSON (for example, for a file). Use MarshalIndent so you can visually verify what you got.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	tasks := []Task{
		NewTask(1, "read about time"),
		NewTask(2, "practice json"),
	}

	b, _ := json.MarshalIndent(tasks, "", "  ")
	fmt.Println(string(b)) // pretty JSON with created_at strings
}

In the output you will see that created_at is a string like "2026-01-16T19:05:10Z". That is exactly what we want for compatibility.

6. Compatibility: which time rules are best to pin down

When you store JSON “for yourself” (for example, a file on disk), it feels like you can do anything. But then import/export, integration, a second service suddenly shows up, and it turns out that “we store the date as 16-01-2026 12:30” is not cute—it is scary.

To make your JSON compatible and predictable, people usually pin down a few simple rules. You can formalize them as a “time contract” for your application.

Below is a small decision table that is useful to keep in mind:

Task Best choice for a basic project Why
“Store a point in time” time.Time in JSON as an RFC3339 string standard, readable, easy to parse
“A field may be missing” *time.Time + omitempty nil explicitly means “absent”
“Print in CLI/logs”
t.UTC().Format(time.RFC3339)
the same on all machines
“Compute a duration”
time.Since(start) / t2.Sub(t1)
correct time arithmetic

One more reason not to invent a format manually: time.Time already implements JSON marshaling so that it looks “reasonable” from the outside. If you start manually formatting a date into a string “the way you like,” then you:

  • lose the ability to automatically Unmarshal back into time.Time,
  • risk getting confused with time zones,
  • almost certainly will break parsing one day because of one extra dot or space.

If you ever need a “special time format,” in Go it is solved via a custom type and marshaling methods, but at the basic level we do not go there: more code, more places for bugs, more sadness.

7. Time decoding errors: how to understand what broke

When Unmarshal cannot read time, you will get an error. And that is good: it is better to stop “broken” time at the input than to silently write something wrong and then spend a week figuring it out.

Two approaches help with diagnostics.

  • The first approach is to just print the whole error, wrapping it with context like “which operation.” This is basic hygiene.
  • The second approach is to distinguish JSON syntax errors (broken JSON) from data errors (the JSON is valid, but a time value did not parse). For example, encoding/json can return typed errors like *json.SyntaxError, which you can extract via errors.As. This technique is often shown as a standard pattern for JSON errors.

Mini example of “broken JSON” (this is not about time.Time directly, but it is useful to understand the error layer):

package main

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

func main() {
	data := []byte(`{"created_at":}`)

	var v map[string]any
	err := json.Unmarshal(data, &v)

	var syn *json.SyntaxError
	if errors.As(err, &syn) {
		fmt.Println("bad JSON at byte:", syn.Offset) // bad JSON at byte: ...
		return
	}
	fmt.Println("other error:", err)
}

And if the JSON is valid but the time is “crooked,” you will get a different error (for example, “cannot parse ...”). From a practical point of view, this is an “input data error,” and it is usually handled as a validation problem (but we will cover full validation in a separate lecture).

8. Common mistakes when working with time.Time in JSON

Mistake #1: store done_at as time.Time and treat time.Time{} as “not set.”
You can do this, but it is very fragile. Zero time 0001-01-01... is a valid value of type time.Time, which means you introduce ambiguity. If the field is truly optional, it is much clearer to use *time.Time, where nil unambiguously means “no value.”

Mistake #2: use local time and be surprised that everything “shifted” on the server.
If you save time.Now() without normalization and then read it in another environment, you become dependent on time zone settings. This is especially “fun” in containers and CI. It is more practical to store time in UTC (time.Now().UTC()), and only for user-facing display pick the needed locale/zone.

Mistake #3: expect omitempty to “make the field optional on read.”
omitempty works only during encoding (Marshal): “if empty, do not write it.” During read (Unmarshal) it does not make JSON “correct” or “incorrect” in any way. Required fields are a separate validation step after Unmarshal.

Mistake #4: compare time as strings.
Time strings look comparable, and sometimes it seems you can do "2026-01-16..." < "2026-02-01..." and be happy. But as soon as different offsets appear (Z vs +03:00) and different precisions (seconds vs nanoseconds), you will step on rakes. Inside your logic, compare time.Time (t1.Before(t2), t1.After(t2)), and use strings only as the external format.

Mistake #5: try to invent your own “human-friendly” date format directly in the JSON contract.
Formats like "16/01/2026 12:30" feel friendly, but in practice quickly become a source of incompatibility: someone expects day/month, someone month/day, someone does not understand the time zone. RFC3339 looks boring, but machines understand it, and humans (if needed) can get a nice display at the UI/CLI level.

1
Task
Go SELF, level 45, lesson 2
Locked
Release event
Release event
1
Task
Go SELF, level 45, lesson 2
Locked
Time normalization
Time normalization
1
Task
Go SELF, level 45, lesson 2
Locked
Optional deadline
Optional deadline
1
Task
Go SELF, level 45, lesson 2
Locked
Input diagnosis
Input diagnosis
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION