The uuid Package (Go 1.27)
The uuid Package (Go 1.27)
Thesis
For a decade the first go get in almost every service was github.com/google/uuid. Go 1.27 adds a standard-library package, imported simply as "uuid", that covers what most programs actually do with UUIDs: generate random (v4) or time-ordered (v7) identifiers, parse the common text forms, compare, sort and serialize them.
The API is intentionally small: 9 functions and methods on one type, type UUID [16]byte. This chapter shows what it does, what it deliberately leaves out, how it behaves with JSON and database/sql (which special-cases it in 1.27, a detail missing from the release notes), and what to watch when migrating from google/uuid.
Cryptography, Hashing, Random and Regexp has a five-line summary. This is the full chapter.
Verified with Go 1.27.2 (linux/amd64) on 2026-10-11. Primary sources: Go 1.27 release notes, uuid package docs, proposal #62026, RFC 9562.
Learning goals
- Generate v4 and v7 UUIDs and know when each is the right key
- Parse the four accepted text forms and handle errors
- Read a UUID’s version, variant and (for v7) timestamp from its bytes
- Store and load UUIDs through JSON and
database/sql, including NULL columns - Migrate from
github.com/google/uuidwithout behaviour surprises
The whole API
type UUID [16]byte
func New() UUID // today: same as NewV4
func NewV4() UUID // 122 random bits
func NewV7() UUID // 48-bit ms timestamp + 12-bit sub-ms fraction + 62 random bits
func Nil() UUID // 00000000-0000-0000-0000-000000000000
func Max() UUID // ffffffff-ffff-ffff-ffff-ffffffffffff
func Parse(s string) (UUID, error)
func MustParse(s string) UUID
func (u UUID) String() string
func (u UUID) Compare(v UUID) int
func (u UUID) MarshalText() ([]byte, error)
func (u UUID) AppendText(b []byte) ([]byte, error)
func (u *UUID) UnmarshalText(b []byte) error
That is the whole list, from go doc -all uuid. There are no v1, v3, v5, v6 or v8 generators, no Version() or Variant() accessors, no node-ID state, and no Scan/Value methods. None of the generators return an error, because random bits come from crypto/rand, which cannot fail as of Go 1.24.
Every listing below is a main.go in its own module whose go.mod says go 1.27. Run each with go run ..
Case 1: generate, and read the version bits
// main.go
package main
import (
"fmt"
"uuid"
)
// The package has no Version method; the version is the high nibble of byte 6.
func version(u uuid.UUID) int { return int(u[6] >> 4) }
// The variant is the top bits of byte 8. RFC 9562 UUIDs use 10xx.
func variant(u uuid.UUID) string {
switch {
case u[8]&0x80 == 0x00:
return "NCS (reserved)"
case u[8]&0xC0 == 0x80:
return "RFC 9562"
case u[8]&0xE0 == 0xC0:
return "Microsoft (reserved)"
default:
return "future (reserved)"
}
}
func main() {
for _, u := range []uuid.UUID{uuid.New(), uuid.NewV4(), uuid.NewV7(), uuid.Nil(), uuid.Max()} {
fmt.Printf("%s v%-2d %s\n", u, version(u), variant(u))
}
}95b51b17-50af-4696-a9b3-c49aa879439b v4 RFC 9562
b31cf2b0-e15a-45f4-ba80-292e8a937217 v4 RFC 9562
01a12b58-8197-781b-b8c9-5ff07a0c47dc v7 RFC 9562
00000000-0000-0000-0000-000000000000 v0 NCS (reserved)
ffffffff-ffff-ffff-ffff-ffffffffffff v15 future (reserved)
Your first three lines will differ, because they are fresh random values. The structure will not: the 13th hex digit is the version (4 or 7), and the 17th digit is 8, 9, a or b (variant bits 10). Nil and Max are special values defined by RFC 9562 sections 5.9 and 5.10. Their “version” and “variant” bits are meaningless, which is why they decode as reserved.
New is documented as “suitable for most purposes” and currently equals NewV4. If your schema depends on the version, call NewV4 or NewV7 explicitly, so a future change to New cannot change your keys.
Case 2: parsing
// main.go
package main
import (
"fmt"
"uuid"
)
func main() {
inputs := []string{
"f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"{F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6}",
"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"f81d4fae7dec11d0a76500a0c91e6bf6",
"f81d4fae-7dec-11d0-a765-00a0c91e6bf",
"f81d4fae_7dec_11d0_a765_00a0c91e6bf6",
"",
}
for _, s := range inputs {
u, err := uuid.Parse(s)
fmt.Printf("%-46q -> %v %v\n", s, u, err)
}
a := uuid.MustParse(inputs[0])
b := uuid.MustParse(inputs[1])
fmt.Println("equal:", a == b)
var zero uuid.UUID
fmt.Println("zero == Nil():", zero == uuid.Nil())
}"f81d4fae-7dec-11d0-a765-00a0c91e6bf6" -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
"{F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6}" -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6" -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
"f81d4fae7dec11d0a76500a0c91e6bf6" -> f81d4fae-7dec-11d0-a765-00a0c91e6bf6 <nil>
"f81d4fae-7dec-11d0-a765-00a0c91e6bf" -> 00000000-0000-0000-0000-000000000000 invalid uuid
"f81d4fae_7dec_11d0_a765_00a0c91e6bf6" -> 00000000-0000-0000-0000-000000000000 invalid uuid
"" -> 00000000-0000-0000-0000-000000000000 invalid uuid
equal: true
zero == Nil(): true
Parseaccepts four forms (hyphenated, braced,urn:uuid:and bare 32 hex digits) in any letter case.Stringalways emits lowercase hyphenated text, so parse-then-print is a canonicalizer.Parseaccepts any version and variant. Version 1 input (...-11d0-...above) parses fine. If your API only issues v7, checku[6]>>4 == 7yourself.- The error is the bare string
invalid uuid, with no input echoed. Wrap it with the field name before returning it to a client. UUIDis an array, so==works, it can be a map key, and its zero value is the Nil UUID.uuid.Nil()is a function, not a variable, so nobody can reassign it.
Case 3: v7 sorts, v4 does not
// main.go
package main
import (
"fmt"
"slices"
"strings"
"uuid"
)
func main() {
const n = 100000
v7 := make([]uuid.UUID, n)
v4 := make([]uuid.UUID, n)
for i := range n {
v7[i] = uuid.NewV7()
v4[i] = uuid.NewV4()
}
cmp := func(a, b uuid.UUID) int { return a.Compare(b) }
fmt.Println("v7 sorted in generation order:", slices.IsSortedFunc(v7, cmp))
fmt.Println("v4 sorted in generation order:", slices.IsSortedFunc(v4, cmp))
// String order matches byte order because String is lowercase fixed-width hex.
strs := make([]string, n)
for i, u := range v7 {
strs[i] = u.String()
}
fmt.Println("v7 strings sorted:", slices.IsSortedFunc(strs, strings.Compare))
fmt.Println("distinct:", len(dedupe(v7)) == n)
}
func dedupe(us []uuid.UUID) map[uuid.UUID]struct{} {
m := make(map[uuid.UUID]struct{}, len(us))
for _, u := range us {
m[u] = struct{}{}
}
return m
}v7 sorted in generation order: true
v4 sorted in generation order: false
v7 strings sorted: true
distinct: true
Compare orders by big-endian bytes (RFC 9562 section 6.11), and because String is fixed-width lowercase hex, string order matches byte order. A v7 primary key in a B-tree index therefore appends at the right-hand edge instead of landing on a random page. That is the reason to use v7 for database keys.
The guarantee is precise. The implementation stores a 12-bit sub-millisecond fraction in rand_a (RFC 9562 section 6.2, method 3). When two calls land on the same timestamp, it bumps the stored value by one tick, so values from one process strictly increase “except when the system clock moves backwards” (package docs). Across processes or hosts, ordering is only as good as the clocks, at roughly millisecond resolution.
Recovering the timestamp
The top 48 bits of a v7 UUID are Unix milliseconds. The package does not expose them, so read them yourself:
// main.go
package main
import (
"encoding/binary"
"fmt"
"time"
"uuid"
)
// v7Time recovers the millisecond Unix timestamp stored in the top 48 bits.
func v7Time(u uuid.UUID) (time.Time, bool) {
if u[6]>>4 != 7 {
return time.Time{}, false
}
ms := binary.BigEndian.Uint64(u[0:8]) >> 16
return time.UnixMilli(int64(ms)).UTC(), true
}
func main() {
before := time.Now().UTC().Truncate(time.Millisecond)
u := uuid.NewV7()
ts, ok := v7Time(u)
fmt.Println(ok, !ts.Before(before), time.Since(ts) < time.Second)
_, ok = v7Time(uuid.NewV4())
fmt.Println(ok)
ts, _ = v7Time(uuid.MustParse("01a12b52-48c5-7905-bd1c-99d5b76a1794"))
fmt.Println(ts.Format(time.RFC3339Nano))
}true true true
false
2026-10-11T14:16:15.301Z
This is useful for debugging and for partition pruning (“which day was this ID minted?”). Do not use it as an authoritative creation time. Anyone who can mint IDs can choose the timestamp, and v7 IDs leak creation time to whoever sees them. If that matters, for example with public invite links, use v4.
Case 4: JSON
// main.go
package main
import (
"encoding/json"
"fmt"
"uuid"
)
type Order struct {
ID uuid.UUID `json:"id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Lines map[uuid.UUID]int `json:"lines"`
}
func main() {
id := uuid.MustParse("01a12b52-48c5-7905-bd1c-99d5b76a1794")
sku := uuid.MustParse("f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
b, err := json.Marshal(Order{ID: id, Lines: map[uuid.UUID]int{sku: 2}})
fmt.Println(string(b), err)
var o Order
err = json.Unmarshal([]byte(`{"id":"{01A12B52-48C5-7905-BD1C-99D5B76A1794}","lines":{}}`), &o)
fmt.Println(o.ID, o.ParentID, err)
err = json.Unmarshal([]byte(`{"id":"not-a-uuid"}`), &o)
fmt.Println(err)
// A zero UUID is not "empty" to omitempty; it is a 16-byte array.
b, _ = json.Marshal(struct {
ID uuid.UUID `json:"id,omitempty"`
}{})
fmt.Println(string(b))
b, _ = json.Marshal(struct {
ID uuid.UUID `json:"id,omitzero"`
}{})
fmt.Println(string(b))
}{"id":"01a12b52-48c5-7905-bd1c-99d5b76a1794","lines":{"f81d4fae-7dec-11d0-a765-00a0c91e6bf6":2}} <nil>
01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil> <nil>
invalid uuid
{"id":"00000000-0000-0000-0000-000000000000"}
{}
MarshalText/UnmarshalText make UUID a JSON string and a valid map key. Two details matter in API code:
omitemptynever omits aUUID, because it is an array, not an empty value. Useomitzero(Go 1.24+) to drop the Nil UUID, or a*uuid.UUIDfor “absent”.- The decode error is just
invalid uuid, without the field name. Validate at the boundary if clients need better messages.
Case 5: database/sql
UUID has no Scan or Value method, yet it works as a query argument and a scan destination, because Go 1.27’s database/sql and database/sql/driver special-case it. This was added in the same change as the package (commit 2fb2b98), but the release notes do not mention it.
// main.go
package main
import (
"database/sql"
"database/sql/driver"
"fmt"
"uuid"
)
func main() {
id := uuid.MustParse("01a12b52-48c5-7905-bd1c-99d5b76a1794")
// uuid.UUID implements neither interface...
_, scanner := any(&id).(sql.Scanner)
_, valuer := any(id).(driver.Valuer)
fmt.Println("sql.Scanner:", scanner, " driver.Valuer:", valuer)
// ...because database/sql special-cases it.
v, err := driver.DefaultParameterConverter.ConvertValue(id)
fmt.Printf("as a query argument: %T %v %v\n", v, v, err)
var a, b, c uuid.UUID
err = sql.ConvertAssign(driver.ScanContext{}, &a, "01A12B52-48C5-7905-BD1C-99D5B76A1794")
fmt.Println("scan from TEXT: ", a, err)
err = sql.ConvertAssign(driver.ScanContext{}, &b, []byte("01a12b52-48c5-7905-bd1c-99d5b76a1794"))
fmt.Println("scan from text bytes:", b, err)
err = sql.ConvertAssign(driver.ScanContext{}, &c, id[:])
fmt.Println("scan from BINARY(16):", c, err)
err = sql.ConvertAssign(driver.ScanContext{}, &a, "nope")
fmt.Println(err)
}sql.Scanner: false driver.Valuer: false
as a query argument: string 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
scan from TEXT: 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
scan from text bytes: 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
scan from BINARY(16): 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
converting driver.Value type string ("nope") to a UUID: invalid uuid
sql.ConvertAssign (new in 1.27, intended for drivers) runs the same conversion that Rows.Scan uses, which makes the rules easy to test without a database:
- Arguments become the canonical 36-character string. If your column is
BINARY(16)orBLOB, passid[:]explicitly. - Scanning accepts a string, a text
[]byte, or exactly 16 raw bytes. - A driver that implements
driver.NamedValueCheckeror the newdriver.RowsColumnScannercan intercept values before these defaults run, for example to map them to a native Postgresuuid. Check your driver’s documentation.
NULL is the trap. Scanning NULL into *uuid.UUID fails, so use sql.Null[uuid.UUID] (Go 1.22+) for nullable columns:
// main.go
package main
import (
"database/sql"
"database/sql/driver"
"fmt"
"uuid"
)
func main() {
var u uuid.UUID
err := sql.ConvertAssign(driver.ScanContext{}, &u, nil) // a NULL column
fmt.Println("NULL into *uuid.UUID:", err)
var n sql.Null[uuid.UUID]
err = n.Scan(nil)
fmt.Println("NULL into sql.Null: ", n.Valid, err)
err = n.Scan("01a12b52-48c5-7905-bd1c-99d5b76a1794")
fmt.Println("TEXT into sql.Null: ", n.Valid, n.V, err)
v, err := n.Value()
fmt.Printf("sql.Null as an argument: %T %v %v\n", v, v, err)
}NULL into *uuid.UUID: unsupported Scan, storing driver.Value type <nil> into type *uuid.UUID
NULL into sql.Null: false <nil>
TEXT into sql.Null: true 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
sql.Null as an argument: string 01a12b52-48c5-7905-bd1c-99d5b76a1794 <nil>
End to end with SQLite
The same behaviour through a real driver, the pure-Go modernc.org/sqlite:
go get modernc.org/sqlite@v1.60.1
go run .// main.go
package main
import (
"database/sql"
"fmt"
"log"
"uuid"
_ "modernc.org/sqlite"
)
func main() {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
_, err = db.Exec(`CREATE TABLE orders (
id TEXT PRIMARY KEY, -- canonical 36-char text
bin_id BLOB NOT NULL, -- 16 raw bytes
parent_id TEXT -- nullable
)`)
if err != nil {
log.Fatal(err)
}
for range 3 {
id := uuid.NewV7()
// id goes in as text automatically; id[:] goes in as 16 bytes.
if _, err := db.Exec(`INSERT INTO orders VALUES (?, ?, NULL)`, id, id[:]); err != nil {
log.Fatal(err)
}
}
rows, err := db.Query(`SELECT id, bin_id, parent_id, typeof(id), length(bin_id) FROM orders ORDER BY id`)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id, bin uuid.UUID
var parent sql.Null[uuid.UUID]
var typ string
var n int
if err := rows.Scan(&id, &bin, &parent, &typ, &n); err != nil {
log.Fatal(err)
}
fmt.Println(id == bin, parent.Valid, typ, n)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
}true false text 16
true false text 16
true false text 16
The id column holds text, bin_id holds 16 bytes, both scan back into the same UUID, and the NULL parent_id arrives as Valid == false.
Case 6: migrating from github.com/google/uuid
Both packages define UUID as [16]byte, so values convert with a plain type conversion. That lets you migrate one package at a time:
go get github.com/google/uuid@v1.6.0
go run .// main.go
package main
import (
"fmt"
guuid "github.com/google/uuid"
"uuid"
)
func main() {
g := guuid.MustParse("01a12b52-48c5-7905-bd1c-99d5b76a1794")
s := uuid.UUID(g) // same [16]byte layout: a plain conversion
fmt.Println(s, s == uuid.MustParse(g.String()))
back := guuid.UUID(s)
fmt.Println(back.Version(), back.Variant())
}01a12b52-48c5-7905-bd1c-99d5b76a1794 true
VERSION_7 RFC4122
github.com/google/uuid v1.6.0 |
stdlib uuid (Go 1.27) |
|---|---|
uuid.New() (panics on failure), uuid.NewRandom() (UUID, error) |
uuid.New(), uuid.NewV4(), no error |
uuid.NewV7() (UUID, error) |
uuid.NewV7(), no error |
uuid.NewString() |
uuid.New().String() |
uuid.Parse, uuid.MustParse |
same names, same four input forms |
uuid.Nil (variable), uuid.Max |
uuid.Nil(), uuid.Max() (functions) |
u.Version(), u.Variant(), u.Time() |
none; read the bits (Cases 1 and 3) |
NewMD5, NewSHA1 (v3/v5), NewUUID (v1), NewV6 |
not provided; keep google/uuid for these |
Scan/Value methods, NullUUID |
built into database/sql; use sql.Null[uuid.UUID] |
SetRand, EnableRandPool |
not provided; tests use testing/cryptotest.SetGlobalRandom |
The compiler catches every signature difference (NewV7 returning two values, Nil becoming a function). Running go vet after the swap is enough to find them.
Deterministic IDs in tests
google/uuid tests often call SetRand to get repeatable IDs. The stdlib package draws from crypto/rand, so use testing/cryptotest.SetGlobalRandom, which seeds every crypto/... randomness source for the duration of one test:
// det_test.go
package det
import (
"testing"
"testing/cryptotest"
"uuid"
)
func TestDeterministic(t *testing.T) {
cryptotest.SetGlobalRandom(t, 1)
a := uuid.NewV4()
cryptotest.SetGlobalRandom(t, 1)
b := uuid.NewV4()
t.Log(a, b, a == b)
}go test -v .=== RUN TestDeterministic
det_test.go:14: 6ae6783f-4fbd-491b-aeb8-8b73a48ed247 6ae6783f-4fbd-491b-aeb8-8b73a48ed247 true
--- PASS: TestDeterministic (0.00s)
PASS
ok example.com/det 0.002s
SetGlobalRandom affects the whole process, so it cannot be used in parallel tests. v7 IDs are harder to pin down. Inside a testing/synctest bubble the clock is fake, so the timestamp is fixed too. But NewV7 keeps process-wide monotonic state, so when two seeded bubbles mint at the same fake instant, the second ID comes out one tick higher. On Go 1.27.2 that gave 00dc6acf-ac00-7000-… and then 00dc6acf-ac00-7001-…. Assert on the structure of v7 IDs, not their exact values.
go doc can now show a dependency’s API at a pinned version without changing go.mod, which helps during a migration: go doc github.com/google/uuid@v1.6.0 NewV7.
Cost: v7 takes a global lock
// bench_test.go
package bench
import (
"testing"
"uuid"
)
var sink uuid.UUID
func BenchmarkNewV4(b *testing.B) {
for b.Loop() {
sink = uuid.NewV4()
}
}
func BenchmarkNewV7(b *testing.B) {
for b.Loop() {
sink = uuid.NewV7()
}
}
func BenchmarkNewV4Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = uuid.NewV4()
}
})
}
func BenchmarkNewV7Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = uuid.NewV7()
}
})
}go test -run '^$' -bench . -count 1 .goos: linux
goarch: amd64
pkg: example.com/bench
cpu: Intel(R) Xeon(R) Processor
BenchmarkNewV4-8 20344930 59.09 ns/op
BenchmarkNewV7-8 10888754 110.7 ns/op
BenchmarkNewV4Parallel-8 23400703 54.17 ns/op
BenchmarkNewV7Parallel-8 4946437 248.3 ns/op
PASS
ok example.com/bench 5.214s
These numbers come from an 8-vCPU cloud VM, and yours will differ. The shape is what matters. NewV7 serializes on a package-level mutex to keep its monotonic guarantee, so under parallel load it gets slower per call, while NewV4 does not. At about 250 ns that still means roughly 4 million IDs per second for the whole process, which is more than any database will insert. It only matters if you mint IDs for in-memory objects in a hot loop. In that case use v4, or batch.
The trap
- Using
New()when the schema needs v7.Newis v4 today and is documented as free to change. - Exposing v7 IDs publicly. They reveal creation time to the millisecond.
omitemptyon aUUIDfield. It never omits. Useomitzeroor a pointer.- Scanning a nullable column into
uuid.UUID. Usesql.Null[uuid.UUID]. - Passing a
UUIDto aBINARY(16)column. It is sent as 36-character text. Passid[:]. - Expecting cross-host ordering from v7. It is monotonic per process only.
The boring rule
Use uuid.NewV7() for database primary keys, uuid.NewV4() for anything public or security-adjacent, and uuid.Parse at every boundary. Keep google/uuid only for name-based v3/v5 IDs.
Try this
- Write
func V7(s string) (uuid.UUID, error)that parses and rejects anything that is not version 7 with the RFC 9562 variant. - Insert 100,000 v4 and 100,000 v7 keys into SQLite tables with
TEXT PRIMARY KEYand compare insert time and file size. - Generate v7 IDs from two goroutines that each call
time.Sleep(0)between calls, and confirm the merged sequence is still sorted.
Sources
- Go 1.27 release notes, “New uuid package”: https://go.dev/doc/go1.27
- Package docs: https://pkg.go.dev/uuid and
go doc -all uuid - Proposal #62026, “uuid: add API to generate and parse UUID”: https://github.com/golang/go/issues/62026
- Implementation commit, including the
database/sqlintegration: https://github.com/golang/go/commit/2fb2b98da3 - RFC 9562, Universally Unique IDentifiers: https://www.rfc-editor.org/rfc/rfc9562
github.com/google/uuidv1.6.0 API, viago doc github.com/google/uuid@v1.6.0