TechEarl

How to Store a UUID in PostgreSQL (the Native uuid Type)

How to store a UUID in PostgreSQL: use the native 16-byte uuid type, not text or varchar(36). Covers gen_random_uuid(), uuid-ossp, why random v4 primary keys hurt index locality, and UUIDv7.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to store a UUID in PostgreSQL using the native 16-byte uuid column type instead of text or varchar(36), with gen_random_uuid() defaults, the uuid-ossp extension, and why random UUIDv4 primary keys hurt index locality versus time-ordered UUIDv7.

PostgreSQL has a native uuid data type, so storing a UUID is genuinely easy: declare the column uuid and you are done. It holds the 128-bit value in 16 bytes, accepts the canonical hyphenated string on insert, and prints it back the same way on SELECT. No BINARY(16) column, no pack-and-unpack functions, no byte-order tricks. This is the single biggest difference from MySQL, which has no UUID column type and makes you store the raw bytes in BINARY(16) and convert by hand.

Short answer: use the native type, id uuid PRIMARY KEY DEFAULT gen_random_uuid(). The column is 16 bytes, the value round-trips as the standard a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 string with zero conversion, and gen_random_uuid() (built into core since PostgreSQL 13) fills it for you. Do not store UUIDs in text or varchar(36): you more than double the storage and throw away the type's validation and index efficiency. The one real decision left is the UUID version for a primary key, covered below.

Why the native uuid type, and not text?

The uuid type has shipped with PostgreSQL since 8.3, so on any version you are realistically running it is just there. Internally it stores the value as the raw 128 bits, which is 16 bytes, exactly the size of the identifier itself. On input it is forgiving: lower-case, upper-case, with or without braces, even with the hyphens in odd places all parse to the same value. On output it always normalizes to the standard lower-case hyphenated form, so what you read is canonical no matter how it went in.

The mistake I see most often in Postgres schemas is a UUID stored as varchar(36) or text. It looks reasonable, the string is 36 characters, so varchar(36) "fits". But it costs you on three fronts at once:

  • Storage. A 36-character ASCII string is 36 bytes of data plus 1 byte of length header in Postgres's variable-length format, so 37 bytes per value against the native type's 16. That is more than double, and it carries straight through into every index on the column.
  • Validation. A uuid column will reject not-a-uuid at insert time. A text column will happily store it, and you find out months later when something downstream tries to parse it.
  • Comparison and indexing. The native type compares 16 bytes; the text version compares up to 36 characters with collation rules in play. Equality lookups and index entries are both smaller and faster on the native type.

There is no upside to the text representation. The value is already human-readable when you SELECT a uuid column, so you do not even gain readability. Use the native type.

Generating the value. You have three sensible options, and they all produce something the uuid column accepts directly:

  • gen_random_uuid() returns a version-4 (random) UUID. It moved into core in PostgreSQL 13; before that it lived in the pgcrypto extension (so on PostgreSQL 12 or earlier you run CREATE EXTENSION pgcrypto; first, and from 13 on the pgcrypto version is just a wrapper around the core function). On PostgreSQL 13+ you need no extension at all.
  • The uuid-ossp extension provides uuid_generate_v1(), uuid_generate_v4(), and the rest of the RFC versions. Enable it with CREATE EXTENSION "uuid-ossp";. Reach for it only if you specifically need a v1 or v3/v5 namespace UUID; for plain random v4 the core gen_random_uuid() is simpler.
  • Your application. Generate the UUID in app code and insert the string. The uuid column takes it as-is.

Comparison table

The choices that actually matter: native type versus the text representations, and which UUID version to reach for as a primary key.

Storage choiceBytes per valueValidates the valueErgonomics
uuid (native)16Yes, rejects non-UUIDsInsert and read the string directly, no functions
varchar(36)37 (36 + length header)No, stores any stringReadable, but wastes space and skips validation
text37 (value + length header)NoSame as varchar(36), no length guard either
UUID version for a PKByte order isIndex locality on insertHow to generate
v4 (random)RandomPoor, every insert hits a random index pagegen_random_uuid() (core 13+)
v7 (time-ordered)ChronologicalGood, inserts append to the right of the indexuuidv7() (core 18+), or app/extension before that

The storage gap is the easy call: the native type is less than half the size of the string forms, every time. The version question is the one worth thinking about, and it is the same trade-off random identifiers have in MySQL when a UUID is your primary key and again in MongoDB's BinData representation: the value's byte order decides whether inserts cluster nicely in the index or scatter across it.

The performance nuance: random v4 as a primary key

This bites people the same way in Postgres as it does in MySQL, just through a slightly different mechanism. Postgres does not physically cluster the table by its primary key the way InnoDB does (the heap is unordered, and the PK is enforced by a separate B-tree index). So you do not get InnoDB's clustered-index page-split problem on the table itself. But the primary-key B-tree index still suffers.

A version-4 UUID is entirely random, so every insert generates a key that lands in a random leaf page of that PK index. On a large index that means the page you need to write to is rarely the one already in cache, so you pay a random read to fetch it, dirty it, and eventually flush it. Insert-heavy workloads end up with poor cache locality, more buffer churn, and more write amplification than a sequential key would cause. Index pages also pack less densely because random inserts cause splits. None of this is fatal, plenty of systems run fine on random UUID PKs, but on a high-write table it is real and measurable.

The fix is a time-ordered UUID: version 7. A v7 UUID puts a millisecond timestamp in its high bits, so successive values sort in roughly creation order. Inserts then land at the right-hand edge of the index instead of scattering, which restores the locality you would get from a bigint sequence while keeping the global uniqueness of a UUID. PostgreSQL 18 adds a built-in uuidv7() function (alongside uuidv4() as an alias for gen_random_uuid()); before 18 you generate v7 in the application (most language UUID libraries now support it) or with a community extension, and store it in the same native uuid column. Nothing about the column changes, only how you fill it.

For a brand-new system I default to UUIDv7 for any UUID primary key. For an existing one on gen_random_uuid() that is not write-bound, leaving it as v4 is a perfectly defensible choice, do not rewrite a working schema for a problem you do not have.

Worked schema: an orders table

Here is the whole thing end to end. An orders table whose primary key is a native uuid defaulting to a freshly generated value, so the application inserts without supplying an id.

sql
CREATE TABLE orders (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id bigint NOT NULL,
  total_cents integer NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- insert without naming the id; the DEFAULT generates one
INSERT INTO orders (customer_id, total_cents) VALUES (42, 1999);

-- read the id back; it prints as the canonical hyphenated string
SELECT id, customer_id, total_cents FROM orders;
-- id                                   | customer_id | total_cents
-- a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 |          42 |        1999

-- look one up by its string id, no conversion needed
SELECT customer_id, total_cents
FROM orders
WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';

Notice what is absent: no UUID_TO_BIN(), no HEX(), no swap_flag. You compare a uuid column to a string literal and Postgres parses the literal to the type for you. To make those primary-key inserts index-friendly on PostgreSQL 18 or later, swap one word in the default:

sql
CREATE TABLE orders (
  id          uuid PRIMARY KEY DEFAULT uuidv7(),
  customer_id bigint NOT NULL,
  total_cents integer NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

On PostgreSQL 12 or earlier, replace gen_random_uuid() with a pgcrypto install first:

sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- then DEFAULT gen_random_uuid() works as above

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsPostgreSQLUUIDDatabase StorageSchema DesignPrimary KeyIndexing

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store an MD5 Hash in PostgreSQL: bytea vs text

How to store an MD5 hash in PostgreSQL: the raw 16-byte bytea you should usually reach for, the readable text/char(32) hex string, and the uuid trick. Storage cost, encode()/decode() instead of HEX/UNHEX, and the pgcrypto digest() function.