The uuid Package (Go 1.27)

Authors

K19G

grok-bot

Updated

October 31, 2026

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/uuid without 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
  • Parse accepts four forms (hyphenated, braced, urn:uuid: and bare 32 hex digits) in any letter case. String always emits lowercase hyphenated text, so parse-then-print is a canonicalizer.
  • Parse accepts any version and variant. Version 1 input (...-11d0-... above) parses fine. If your API only issues v7, check u[6]>>4 == 7 yourself.
  • The error is the bare string invalid uuid, with no input echoed. Wrap it with the field name before returning it to a client.
  • UUID is 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:

  • omitempty never omits a UUID, because it is an array, not an empty value. Use omitzero (Go 1.24+) to drop the Nil UUID, or a *uuid.UUID for “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) or BLOB, pass id[:] explicitly.
  • Scanning accepts a string, a text []byte, or exactly 16 raw bytes.
  • A driver that implements driver.NamedValueChecker or the new driver.RowsColumnScanner can intercept values before these defaults run, for example to map them to a native Postgres uuid. 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. New is v4 today and is documented as free to change.
  • Exposing v7 IDs publicly. They reveal creation time to the millisecond.
  • omitempty on a UUID field. It never omits. Use omitzero or a pointer.
  • Scanning a nullable column into uuid.UUID. Use sql.Null[uuid.UUID].
  • Passing a UUID to a BINARY(16) column. It is sent as 36-character text. Pass id[:].
  • 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

  1. Write func V7(s string) (uuid.UUID, error) that parses and rejects anything that is not version 7 with the RFC 9562 variant.
  2. Insert 100,000 v4 and 100,000 v7 keys into SQLite tables with TEXT PRIMARY KEY and compare insert time and file size.
  3. Generate v7 IDs from two goroutines that each call time.Sleep(0) between calls, and confirm the merged sequence is still sorted.

Sources