How to Convert Unix Timestamp to time.Time in Go

Convert a Unix timestamp to Go's time.Time using the time.Unix function with seconds and nanoseconds arguments.

The number on the wire isn't a date

You are parsing a response from a legacy API. The JSON payload contains a field "created_at": 1715625600. You need to display this as "May 13, 2024" in a UI, or compare it against a deadline to see if a token has expired. You have an integer. You need a time.Time.

Go does not guess. It does not treat integers as dates implicitly. The standard library provides a precise bridge between raw timestamps and the rich time.Time type. That bridge is time.Unix.

A Unix timestamp is a coordinate on a timeline: the number of seconds elapsed since 00:00:00 UTC on January 1, 1970. time.Time is the address: it carries the date, the time, the timezone, and the nanosecond precision. time.Unix translates the coordinate into the address.

Converting seconds to time

The standard function is time.Unix(sec int64, nsec int64) time.Time. It takes seconds since the epoch and an optional nanosecond offset. It returns a time.Time value.

Here's the minimal conversion: pass the seconds, pass zero for nanoseconds if you don't have them, and get a UTC time back.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Unix timestamp: seconds since 1970-01-01 00:00:00 UTC.
	sec := int64(1715625600)

	// Nanoseconds for sub-second precision.
	// Pass 0 if the source only provides whole seconds.
	nsec := int64(0)

	// time.Unix returns a time.Time value in UTC.
	// It normalizes the inputs, so nsec can exceed 999,999,999.
	t := time.Unix(sec, nsec)

	fmt.Println(t)
}

The result is a time.Time struct. It is not a pointer. It is not a reference. You can pass it around by value without fear of aliasing or performance penalties. The struct is small enough that copying it is cheaper than dereferencing a pointer.

What happens under the hood

When you call time.Unix, the runtime constructs a time.Time value. The location is set to UTC by default. This is a deliberate design choice. UTC is the canonical timezone for storage, transmission, and comparison. Local time depends on the machine's configuration, which varies across servers and users.

The function normalizes the nanoseconds. If you pass nsec greater than 999,999,999, time.Unix adds the overflow to the seconds and resets the nanoseconds. If you pass a negative nsec, it borrows from the seconds. You don't need to clamp the values manually. The standard library handles the arithmetic.

// NormalizationDemo shows how time.Unix handles out-of-range nanoseconds.
func NormalizationDemo() {
	// Pass 1,000,000,000 nanoseconds.
	// This is exactly one second.
	// time.Unix adds one to the seconds and resets nanoseconds to zero.
	t := time.Unix(100, 1_000_000_000)

	// The result is equivalent to time.Unix(101, 0).
	fmt.Println(t.Unix()) // prints 101
}

Normalization means you can be lazy with the inputs. If your source gives you seconds and milliseconds, you can multiply the milliseconds by 1_000_000 and pass the result as nsec. time.Unix will fix the rollover.

Real-world timestamps are messy

Production data rarely arrives as clean int64 seconds. Databases often store milliseconds. JavaScript environments serialize timestamps as floating-point numbers. JSON parsers might hand you a float64.

When the timestamp is in milliseconds, use time.UnixMilli. It was added in Go 1.17 to avoid the manual multiplication and to signal intent.

// HandleMilliTimestamp converts a millisecond timestamp to time.Time.
// Go 1.17 introduced time.UnixMilli for this exact case.
func HandleMilliTimestamp(ms int64) time.Time {
	// time.UnixMilli takes milliseconds directly.
	// It avoids the multiplication and division overhead of manual conversion.
	return time.UnixMilli(ms)
}

When the timestamp is a float, you have to split it. Floating-point numbers cannot represent all integers exactly. Large timestamps lose precision if you round-trip them through float64. Split the float into seconds and fractional seconds, then convert the fraction to nanoseconds.

package main

import (
	"fmt"
	"math"
	"time"
)

// ParseFloatTimestamp handles timestamps that arrive as floating-point numbers.
// This is common when bridging with JavaScript or JSON APIs.
func ParseFloatTimestamp(ts float64) time.Time {
	// Split the float into whole seconds and fractional seconds.
	// math.Trunc keeps the integer part; math.Mod gets the remainder.
	sec := int64(math.Trunc(ts))
	frac := math.Mod(ts, 1.0)

	// Convert the fractional part to nanoseconds.
	// A fraction of 0.5 means 500,000,000 nanoseconds.
	nsec := int64(frac * 1e9)

	// time.Unix normalizes the result.
	// If nsec exceeds 999,999,999, it rolls over into sec automatically.
	return time.Unix(sec, nsec)
}

func main() {
	// Example: 1715625600.123456789
	t := ParseFloatTimestamp(1715625600.123456789)
	fmt.Println(t)
}

Float precision is a silent killer. float64 has 53 bits of mantissa. int64 has 64 bits. Any timestamp larger than 2^53 seconds loses precision when stored as a float. That threshold is around the year 2262. If your system handles dates far in the future, or if you need exact millisecond accuracy for large values, keep the data as int64 until the last possible moment.

If you try to pass a float64 directly to time.Unix, the compiler rejects the program with cannot use ts (variable of type float64) as int64 value in argument to time.Unix. Go forces you to acknowledge the conversion.

Pitfalls and conventions

Timezones cause the most bugs. time.Unix returns UTC. If you need local time, call .Local() on the result. If you need a specific timezone, call .In(loc). Never assume time.Unix respects the system timezone. It does not.

// ToLocal converts a Unix timestamp to the system's local timezone.
func ToLocal(sec int64) time.Time {
	// time.Unix returns UTC.
	// .Local() converts the instant to the local timezone.
	// The underlying instant does not change; only the representation changes.
	return time.Unix(sec, 0).Local()
}

Another convention is value semantics. time.Time is a struct. Pass it by value. Do not pass *time.Time unless you need nil to represent "no time". The struct has an IsZero() method that checks if the time is the zero value. Use IsZero() instead of pointer nil checks.

// CheckExpiration demonstrates value semantics with time.Time.
// It accepts a time by value, not a pointer.
func CheckExpiration(deadline time.Time) bool {
	// time.Time is safe to pass by value.
	// Copying the struct is cheap and avoids pointer aliasing.
	return time.Now().After(deadline)
}

If you receive a timestamp from a database and it might be null, use a pointer for the database scan, but convert to a value immediately after. Keep pointers at the boundary.

Error handling is another convention. time.Unix does not return an error. It is a conversion, not a parse. If you pass garbage numbers, you get a garbage time. The compiler cannot check the range at compile time. Validate the input before calling time.Unix if the source is untrusted.

Formatting is the final step. Once you have a time.Time, use .Format() with a layout string. Go uses a reference time for layouts, not format codes. The reference time is Mon Jan 2 15:04:05 MST 2006. Use time.RFC3339 for standard ISO8601 output.

Unix timestamps are coordinates. time.Time is the calendar. Convert early, format late.

When to use what

Time conversion has many paths. Pick the tool that matches your input.

Use time.Unix when you have a standard Unix timestamp in seconds, possibly with nanosecond precision.

Use time.UnixMilli when your data source provides milliseconds, which is common in databases and JavaScript environments.

Use time.Parse when the timestamp arrives as a formatted string like RFC3339 or ISO8601.

Use time.Date when you have separate year, month, and day values that need assembly into a time.

Use time.Now() when you need the current wall-clock time for logging or deadlines.

Float64 loses precision on large integers. Keep timestamps as int64 until the last possible moment.

Pass time.Time by value. Use IsZero() for absence. Reserve pointers for optional JSON fields.

Trust time.Unix to normalize. Pass wild nanoseconds and let the standard library handle the rollover.

Where to go next