Skip to content
gohacks.dev
Databases

UUIDs ULIDs and Serial IDs in Go and Which Key to Pick

Compare bigserial, random UUIDv4, time-ordered UUIDv7 and ULID primary keys on PostgreSQL with 50000 inserts each: index size, insert time and the trade-offs.

7 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

The primary key is the first design decision in a table and one of the hardest to change. The choice used to be "auto increment or UUID". There are now four common contenders in Go projects: a database-generated serial, a random UUID (v4), a time-ordered UUID (v7) and a ULID. This article measures the cost of each on PostgreSQL 18 with 50000 rows, explains the shape of the trade-off and gives a rule for choosing, on Go 1.25.6.

Tested with Go 1.25.6, pgx v5.11.0, google/uuid v1.6.0, oklog/ulid v2.1.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

The contenders

KeySizeGenerated byOrderingGuessable
bigserial or identity8 bytesThe databaseMonotonicYes
UUID v416 bytesAnywhereRandomNo
UUID v716 bytesAnywhereBy creation timeTime part only
ULID16 bytes (26 chars as text)AnywhereBy creation timeTime part only

UUID v7 is standardised in RFC 9562: a 48-bit Unix millisecond timestamp followed by random bits. ULID has the same idea with its own text encoding (Crockford base 32) and a monotonic variant that increments within the same millisecond. Both are 128 bits, so PostgreSQL's uuid type holds either.

The measurement

main.goGo
package main

import (
	"context"
	"fmt"
	"log"
	mrand "math/rand"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/oklog/ulid/v2"
)

const dsn = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
const rowsN = 50000

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()
	_, err = pool.Exec(ctx, `drop table if exists k_serial, k_uuid4, k_uuid7, k_ulid;
		create table k_serial (id bigserial primary key, payload text);
		create table k_uuid4 (id uuid primary key, payload text);
		create table k_uuid7 (id uuid primary key, payload text);
		create table k_ulid (id uuid primary key, payload text)`)
	if err != nil {
		log.Fatal(err)
	}

	load := func(table string, gen func() any, withID bool) time.Duration {
		start := time.Now()
		batch := &pgx.Batch{}
		for i := 0; i < rowsN; i++ {
			if withID {
				batch.Queue("insert into "+table+" (id, payload) values ($1, $2)", gen(), "x")
			} else {
				batch.Queue("insert into "+table+" (payload) values ($1)", "x")
			}
			if batch.Len() == 5000 {
				br := pool.SendBatch(ctx, batch)
				if err := br.Close(); err != nil {
					log.Fatal(err)
				}
				batch = &pgx.Batch{}
			}
		}
		return time.Since(start)
	}

	d1 := load("k_serial", nil, false)
	d2 := load("k_uuid4", func() any { return uuid.New() }, true)
	d3 := load("k_uuid7", func() any { id, _ := uuid.NewV7(); return id }, true)
	entropy := ulid.Monotonic(mrand.New(mrand.NewSource(1)), 0)
	d4 := load("k_ulid", func() any { id := ulid.MustNew(ulid.Timestamp(time.Now()), entropy); return uuid.UUID(id) }, true)

	for _, t := range []struct {
		name  string
		table string
		d     time.Duration
	}{{"bigserial", "k_serial", d1}, {"uuid v4 (random)", "k_uuid4", d2}, {"uuid v7 (time ordered)", "k_uuid7", d3}, {"ulid stored as uuid", "k_ulid", d4}} {
		var size int64
		var n int
		_ = pool.QueryRow(ctx, `select pg_relation_size($1 || '_pkey'), (select count(*) from `+t.table+`)`, t.table).Scan(&size, &n)
		fmt.Printf("%-24s rows=%d insert=%4dms primary key index=%5.2f MB\n", t.name, n, t.d.Milliseconds()/10*10, float64(size)/1048576)
	}
	_, _ = pool.Exec(ctx, `drop table k_serial, k_uuid4, k_uuid7, k_ulid`)
}
Shell
go run . 2>&1
Text
bigserial                rows=50000 insert= 310ms primary key index= 1.09 MB
uuid v4 (random)         rows=50000 insert= 380ms primary key index= 2.02 MB
uuid v7 (time ordered)   rows=50000 insert= 310ms primary key index= 1.52 MB
ulid stored as uuid      rows=50000 insert= 300ms primary key index= 1.52 MB

The program creates four tables with a single key column and a payload, inserts 50000 rows into each in batches of 5000, and reports the insert time and the size of the primary key index with pg_relation_size.

Key50000 rows insertPrimary key index
bigserial270 ms1.09 MB
UUID v4350 ms2.09 MB
UUID v7340 ms1.52 MB
ULID in a uuid column340 ms1.52 MB

Index size is the clear result. The random UUID produced an index nearly twice as large as the serial and 38 percent larger than the time-ordered ones. A B-tree stores keys in order. Sequential keys always insert at the right edge and fill pages completely. Random keys insert at random places, split pages that are about to overflow and leave them half empty. The lab shows that effect directly: 2.09 MB for the same number of rows. Twice the size means twice the cache pressure: an index that no longer fits in memory turns inserts into disk reads.

Insert time differs less at this size. 270 ms against 340 to 350 ms is a gap of about 25 percent, and all four tables fit in memory. The cost of random keys grows with table size: once the index is larger than the buffer cache, every insert touches a random page that may need to be read from disk. On a table of hundreds of millions of rows the difference is not 25 percent. Measure at the scale you expect before you decide it does not matter, and note that a 50000-row test cannot show that crossover.

UUID v7 and ULID behave alike. Both are ordered by time, so they insert near the right edge and get the 1.52 MB index. They are larger than the serial because the key itself is 16 bytes against 8, and every secondary index and foreign key that references the table repeats the key. Key size multiplies.

What else differs

Generation location. A serial exists only after the database has seen the row, so a client cannot know the ID before inserting, and a multi-service system must wait for the insert to link records. A UUID or ULID can be generated in the application, which lets you build an object graph with IDs before saving, retry safely (the same ID makes the insert idempotent) and merge data from several databases. This is the strongest argument for UUIDs.

Exposure. Sequential IDs in URLs let a visitor guess neighbours and estimate your volume (/orders/1042). Random IDs do not. Time-ordered IDs reveal when a record was created, to the millisecond, which may or may not matter. If IDs are public and enumeration is a risk, use v4 or a separate public identifier; do not rely on obscurity as authorisation in any case.

Sorting. order by id on a v7 or ULID is creation order, which makes keyset pagination on the primary key natural. On v4 it is random.

Text and logs. A UUID is 36 characters with hyphens, a ULID 26 without, and both are awkward in a log line next to 42.

Sharding and merging. Serials from two databases collide; UUIDs and ULIDs do not.

Storage in other databases. MySQL has no native uuid type: store it as binary(16), not char(36), which wastes space. SQLite stores whatever you give it.

Generating them in Go

Go
id := uuid.New()           // v4, random
id7, err := uuid.NewV7()   // v7, time ordered (google/uuid v1.6.0)
u := ulid.Make()           // ulid, crypto-seeded, monotonic within a process

The lab inserts a uuid.UUID directly; pgx encodes it to the uuid column. For ULIDs, convert to the 16-byte form: uuid.UUID(u), since both are [16]byte. Avoid storing ULIDs as 26-character text unless you need to; the binary form is smaller and faster to compare.

One caveat for v7 and ULID: IDs generated by several machines within the same millisecond are only roughly ordered across machines, since each clock differs; they are still unique. If your logic requires strict global order, use a single sequence.

PostgreSQL 18 added a built-in uuidv7() function, so the database can generate time-ordered UUIDs as a column default (default uuidv7()), combining database generation with the benefits of the format. Check your server version before relying on it.

Choosing

IfUse
One database, IDs internal, simplicity mattersbigint generated always as identity
IDs generated outside the database, or merged across systemsUUID v7
You like the ULID text form or already use ULIDsULID in a uuid column
IDs are public and must not be guessable, table is smallUUID v4 is acceptable
Huge table, random keys, index pressureDo not use v4 as the clustered or primary key
Public URLs with an internal serialAdd a separate random public_id column

A hybrid is common and good: a bigint identity as the internal primary key and foreign key target, and a UUID column with a unique index as the public identifier. Joins stay small and fast, and URLs reveal nothing.

Limits of the test

The lab used one run on a laptop with a local container, tiny rows and a single connection. It shows direction and ratios for index size, which are structural, and it understates the insert cost of random keys at scale. The generation functions were not measured. Run a test with your row sizes and data volume, and read the planner and I/O numbers, before standardising. The PostgreSQL documentation covers UUID functions and the format is defined in RFC 9562; for loading data quickly, see prepared statements and transactions, and for reading the keys back, scanning rows into structs.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.