DEV Community

Cover image for Go 1.27 Ships a uuid Package. Dropping google/uuid Takes 4 Steps and One NULL Catch
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Go 1.27 Ships a uuid Package. Dropping google/uuid Takes 4 Steps and One NULL Catch


Open a Go service's go.mod and there's a good chance github.com/google/uuid is in it. Its latest tag, v1.6.0, dates from January 2024. Since Go 1.27 shipped on 2026-08-19, you no longer need it for the common cases. The release notes say it in one line: "The new uuid package generates and parses UUIDs."

The import path is just uuid. The swap looks like a find-and-replace on the import line. It mostly is. Two things will fail to compile, which is good, because the compiler finds them for you. One thing compiles fine and then fails at runtime on the first row with a NULL in it. That one is the reason for this post.

What the stdlib package gives you

The package docs describe a small surface, built on RFC 9562:

  • type UUID [16]byte, comparable with ==.
  • New() for general use, currently the same as NewV4().
  • NewV4() (122 random bits) and NewV7() (48-bit millisecond timestamp plus at least 62 random bits).
  • Parse, MustParse, and the functions Nil() and Max().
  • Methods: String, Compare, MarshalText, UnmarshalText, AppendText.

Parse accepts the hyphenated form, the same form in curly braces, the urn:uuid: prefix, and 32 bare hex digits, in either case. That matches the input forms google/uuid accepts, so stored strings keep parsing.

What's missing compared to google/uuid: no v1, v3, v5 or v6 constructors, no Version() or Time() methods, no MarshalBinary, and no NullUUID type. This write-up of the accepted proposal walks through why the API stayed this small; the discussion lives in golang/go#62026.

Here is the service we'll migrate. It's the shape most code has: a v7 ID generator, a nil check, and a table with a nullable foreign key.

package orders

import (
    "database/sql"

    "github.com/google/uuid"
)

type Order struct {
    ID       uuid.UUID
    ParentID uuid.UUID // uuid.Nil when no parent
}

func NewOrder() (Order, error) {
    id, err := uuid.NewV7()
    if err != nil {
        return Order{}, err
    }
    return Order{ID: id}, nil
}

func (o Order) IsRoot() bool {
    return o.ParentID == uuid.Nil
}

func Load(db *sql.DB, id uuid.UUID) (Order, error) {
    var o Order
    err := db.QueryRow(
        `SELECT id, parent_id FROM orders WHERE id = $1`,
        id,
    ).Scan(&o.ID, &o.ParentID)
    return o, err
}
Enter fullscreen mode Exit fullscreen mode

Step 1: bump the go directive first

Do this before you touch an import:

go mod edit -go=1.27
Enter fullscreen mode Exit fullscreen mode

The compiler won't stop you from importing uuid while go.mod still says go 1.26. Your tests will. Go 1.27 made go test run the stdversion vet check by default, and that check "reports the use of standard library symbols that are too new for the Go version in force in the referring file, as determined by go directive in go.mod and build tags on the file." Skip the bump and go build passes, then go test fails, on your machine and in CI.

Watch for files with their own //go:build go1.26 style constraints too. The check reads build tags as well as go.mod.

Step 2: swap the import and follow the compiler

Change the import to "uuid", run go build ./..., and fix what breaks. Two errors show up in almost every codebase.

NewV7 no longer returns an error. In google/uuid the signature is func NewV7() (UUID, error). In the stdlib it is func NewV7() UUID. Same story for uuid.Must(uuid.NewRandom()), which becomes uuid.NewV4().

Nil is a function now. In google/uuid, Nil and Max are variables. In the stdlib they are func Nil() UUID and func Max() UUID, so x == uuid.Nil compares a UUID against a function and fails to compile. Add the parentheses.

func NewOrder() Order {
    return Order{ID: uuid.NewV7()}
}

func (o Order) IsRoot() bool {
    return o.ParentID == uuid.Nil()
}
Enter fullscreen mode Exit fullscreen mode

If your code called id.Time() or id.Version(), you have to write those yourself. Both are fixed bit positions in RFC 9562: the version is the high nibble of byte 6, and a v7 timestamp is the first 48 bits, in milliseconds.

func Version(u uuid.UUID) int {
    return int(u[6] >> 4)
}

func V7Time(u uuid.UUID) time.Time {
    ms := int64(u[0])<<40 | int64(u[1])<<32 |
        int64(u[2])<<24 | int64(u[3])<<16 |
        int64(u[4])<<8 | int64(u[5])
    return time.UnixMilli(ms)
}
Enter fullscreen mode Exit fullscreen mode

Step 3: migrate package by package with a cast

In a big repo you won't flip every package in one PR. You don't have to. Both types have the same underlying type, [16]byte, so Go lets you convert between them directly. Import the old package under an alias at the seam:

import (
    guuid "github.com/google/uuid"
    "uuid"
)

func fromLegacy(id guuid.UUID) uuid.UUID {
    return uuid.UUID(id)
}

func toLegacy(id uuid.UUID) guuid.UUID {
    return guuid.UUID(id)
}
Enter fullscreen mode Exit fullscreen mode

The conversion copies 16 bytes and nothing else. The JSON wire format doesn't change either: both packages implement MarshalText with the lowercase hyphenated string, so encoding/json writes the same string for both types.

While you're here, use what v7 buys you. The docs say NewV7 "always returns UUIDs which sort in increasing order, except when the system clock moves backwards", and Compare orders by big-endian bytes per RFC 9562 §6.11. A method expression drops straight into slices.SortFunc:

ids := []uuid.UUID{
    uuid.NewV7(), uuid.NewV7(), uuid.NewV7(),
}
slices.SortFunc(ids, uuid.UUID.Compare)

slices.SortFunc(orders, func(a, b Order) int {
    return a.ID.Compare(b.ID)
})
Enter fullscreen mode Exit fullscreen mode

For v7 IDs, that's creation order without a separate created_at sort key, as long as the clocks behave.

Step 4: the database, and the NULL catch

The stdlib type has no Scan or Value methods. Go 1.27 doesn't need them, because database/sql itself knows about uuid.UUID:

  • Writing: the default parameter converter has a case uuid.UUID: return vr.String(), nil branch (source at go1.27.3). That's the same string google/uuid's Value() returns.
  • Reading: Rows.Scan lists *uuid.UUID as a supported destination. "The source may contain the string representation of a UUID, or a 16-byte []byte."

So Load compiles and works unchanged for rows that have a parent. Then it hits a root order with parent_id IS NULL.

google/uuid's Scan returns nil on a nil source without touching the destination (so a fresh variable stays uuid.Nil), and it does the same for an empty string. The stdlib path does neither. In convertAssignRows, the nil source branch only handles *any, *[]byte and *RawBytes. A NULL scanned into *uuid.UUID falls through to the generic "unsupported Scan" error. An empty string goes through uuid.Parse and fails there.

Your old code relied on NULL quietly becoming uuid.Nil. With the default conversion, the scan now errors on the first root order. Nothing flags it at compile time, and a test suite that only seeds child orders will pass.

The fix is the generic sql.Null[T], which has been in the stdlib since Go 1.22. Its Scan handles nil and delegates everything else to the same conversion, and its Value runs through the default converter, so a valid UUID is written as a string:

func Load(db *sql.DB, id uuid.UUID) (Order, error) {
    var o Order
    var parent sql.Null[uuid.UUID]
    err := db.QueryRow(
        `SELECT id, parent_id FROM orders WHERE id = $1`,
        id,
    ).Scan(&o.ID, &parent)
    if err != nil {
        return Order{}, err
    }
    if parent.Valid {
        o.ParentID = parent.V
    }
    return o, nil
}
Enter fullscreen mode Exit fullscreen mode

If you used uuid.NullUUID, the field names change: .UUID becomes .V, .Valid stays.

One more check before you ship. The default converter only runs when your driver lets it. The driver.Value docs note that a driver implementing NamedValueChecker handles argument types itself. If yours does, read its docs for how it treats a [16]byte before you assume the string path. Reads have the same escape hatch: Go 1.27 lets a driver implement RowsColumnScanner and "scan directly into user-provided destinations", so the NULL error above is what database/sql does by default, and a driver that scans for itself may behave differently. Grep for every column you scan into a UUID, find the nullable ones, and write one test that loads a NULL. That one test covers the catch.


If this was useful

The swap is small because both types share [16]byte and database/sql already knows the new one. The Complete Guide to Go Programming covers that layer, the type system and the standard library, along with the runtime behaviour that decides whether a change like this is a cast or a rewrite. If you'd rather understand the stdlib than memorise its signatures, start with the book.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)