Published 2026-07-17 | Last Updated: 2026-07-25
How to Store UUIDs Correctly in PostgreSQL (uuid type vs text vs varchar(36))
Postgres has a dedicated uuid type, yet a large fraction of schemas store UUIDs as text or varchar(36) instead - usually inherited from an ORM default, a migration from another database, or a developer treating a UUID as "just a string that happens to look like one." This post covers what the uuid type actually does differently at the storage and validation level, and why the alternative isn't just a stylistic choice but a measurable cost.
The Three Representations
| Type | On-disk size | Format validation | Comparison semantics |
|---|---|---|---|
uuid (native) |
16 bytes, fixed | Enforced at input | Byte-wise binary comparison |
varchar(36) |
37 bytes (1-byte length prefix + up to 36 chars) | None - any string ≤36 chars is accepted | Byte-wise string comparison |
text |
Variable, ~37 bytes for a UUID string | None | Byte-wise string comparison, same as varchar for this length |
The size difference alone is worth internalizing: uuid is 16 bytes; the string forms are effectively 37 bytes - including a 1-byte (or 4-byte, if TOAST-eligible at larger sizes, though UUIDs never reach that threshold) length header. That's more than double the storage per value, before indexes are even considered.
Mechanics: What the uuid Type Actually Does
Postgres's uuid type is a fixed-length, 16-byte internal binary type, defined in src/include/utils/uuid.h in the Postgres source as a unsigned char[UUID_LEN] (UUID_LEN = 16). Two things follow directly from that definition:
1. Input is validated and normalized at write time.
-- This succeeds: valid RFC 4122 format, case-insensitive
INSERT INTO customers (id) VALUES ('550e8400-e29b-41d4-a716-446655440000');
-- This fails immediately: malformed UUID
INSERT INTO customers (id) VALUES ('not-a-uuid');
-- ERROR: invalid input syntax for type uuid: "not-a-uuid"
A varchar(36) column enforces none of this - 'not-a-uuid' inserts without complaint, silently corrupting the assumption that every value in the column is a real UUID. The application layer has to reimplement the validation uuid gives for free.
2. Comparisons and sorts operate on the raw 16 bytes, not on hyphens and hex characters.
SELECT '550e8400-e29b-41d4-a716-446655440000'::uuid =
'550E8400-E29B-41D4-A716-446655440000'::uuid;
-- true - case-insensitive because comparison happens on the decoded bytes
The same comparison on varchar values depends entirely on collation settings and byte-for-byte string equality - case sensitivity becomes a collation question rather than a guaranteed property of the type.
Migrating an Existing Column
If a table already has UUIDs stored as varchar(36) or text, converting to the native type is a single, safe cast in Postgres, because the string values are already valid UUID literals:
ALTER TABLE customers
ALTER COLUMN id TYPE uuid USING id::uuid;
The USING id::uuid clause is required - Postgres won't implicitly cast text/varchar to uuid even though the conversion is unambiguous, since a change of this kind should be explicit. On a large table, this rewrite locks the table for its duration; for zero-downtime migrations, the standard pattern is:
-- 1. Add a new column
ALTER TABLE customers ADD COLUMN id_uuid uuid;
-- 2. Backfill in batches (application-driven or a loop with LIMIT/OFFSET)
UPDATE customers SET id_uuid = id::uuid WHERE id_uuid IS NULL;
-- 3. Swap once backfilled and verified, inside a short transaction
BEGIN;
ALTER TABLE customers DROP COLUMN id;
ALTER TABLE customers RENAME COLUMN id_uuid TO id;
ALTER TABLE customers ALTER COLUMN id SET NOT NULL;
COMMIT;
Measuring the Difference Directly
Rather than trusting the byte-count arithmetic alone, it's worth confirming it against a real table:
CREATE TABLE bench_uuid_native (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload TEXT DEFAULT 'x'
);
CREATE TABLE bench_uuid_text (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
payload TEXT DEFAULT 'x'
);
INSERT INTO bench_uuid_native DEFAULT VALUES;
INSERT INTO bench_uuid_native (payload)
SELECT 'x' FROM generate_series(1, 2_000_000);
INSERT INTO bench_uuid_text (payload)
SELECT 'x' FROM generate_series(1, 2_000_000);
SELECT
relname,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS index_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname IN ('bench_uuid_native', 'bench_uuid_text');
Representative output on Postgres 16 with 2,000,000 rows:
| Table | Table size | Index size |
|---|---|---|
bench_uuid_native |
130 MB | 96 MB |
bench_uuid_text |
168 MB | 122 MB |
The text-backed table runs roughly 29% larger and its index about 27% larger - consistent with the 16-vs-37-byte theoretical ratio, moderated by fixed per-row/per-entry overhead that doesn't scale with key width.
Application-Layer Code
C# / EF Core
public class Customer
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = default!;
}
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
// Npgsql maps Guid <-> uuid automatically - no explicit column type needed
builder.Property(c => c.Id)
.HasColumnType("uuid");
}
}
The common mistake here is mapping Id as string instead of Guid - that forces Npgsql to send/receive the value as text, which either requires an explicit cast in every query or, worse, causes EF to generate the column as text/varchar in the first place if the migration is scaffolded from that mapping. Always model UUID columns as Guid in C#, never string, unless there's a specific reason to bypass native typing.
Raw ADO.NET / Npgsql
await using var cmd = new NpgsqlCommand(
"INSERT INTO customers (id, email) VALUES (@id, @email)", connection);
cmd.Parameters.AddWithValue("id", NpgsqlDbType.Uuid, Guid.NewGuid());
cmd.Parameters.AddWithValue("email", "[email protected]");
await cmd.ExecuteNonQueryAsync();
Explicitly specifying NpgsqlDbType.Uuid avoids any ambiguity in how the parameter is sent over the wire protocol.
Node.js (node-postgres)
const { Client } = require('pg');
const { randomUUID } = require('crypto');
const client = new Client();
await client.connect();
await client.query(
'INSERT INTO customers (id, email) VALUES ($1, $2)',
[randomUUID(), '[email protected]']
);
node-postgres sends the UUID as text over the wire regardless, but the column type on the server side is what determines storage and validation - the client-side representation doesn't need to match the server storage format, only be parseable into it.
Comparison With Alternatives
| Approach | Storage per value | Validated at DB level | Sort/comparison correctness | When to use |
|---|---|---|---|---|
uuid (native) |
16 bytes | Yes | Guaranteed, byte-based | Default choice for any UUID column |
varchar(36) |
37 bytes | No | Depends on collation | Legacy schemas not yet migrated; avoid for new tables |
text |
~37 bytes | No | Depends on collation | No advantage over varchar(36) for fixed-format data; avoid |
BINARY(16)-equivalent via bytea |
16 bytes + 1-4 byte header | No format enforcement | Byte-based, but no UUID-specific validation | Rarely justified in Postgres - uuid already provides this natively |
There's no scenario in Postgres where text/varchar(36) outperforms or out-validates the native uuid type - the only reason to use them is inertia from an existing schema, a database migrated from a system that lacked a native UUID type (e.g., older MySQL versions), or an ORM default that was never revisited.
Indexing Considerations
The type choice also affects how well LIKE-based partial matching works, which sometimes motivates a mistaken preference for text storage:
-- Doesn't work on native uuid type - no implicit text semantics
SELECT * FROM customers WHERE id::text LIKE '550e8400%';
-- Requires an explicit cast, and can't use a standard uuid btree index efficiently
If prefix search on UUIDs is a real requirement, the correct fix is a functional index, not switching the column to text:
CREATE INDEX idx_customers_id_text ON customers ((id::text) text_pattern_ops);
This keeps the primary storage in the compact, validated uuid type while still supporting the prefix-search use case through a secondary, purpose-built index.
Conclusion
The native uuid type in Postgres isn't a cosmetic convenience, it halves storage per value versus text-based alternatives, enforces RFC 4122 format at write time, and guarantees comparison semantics that don't depend on collation settings. Every new schema should default to uuid for UUID columns; every existing schema storing them as text/varchar(36) has a low-risk, well-defined migration path to correct it, and should treat that migration as a real (if not always urgent) technical debt item rather than a stylistic nitpick.
Related Tools and Guides
Need some UUIDs for testing? Try our tools for generating
UUID v4
UUID v7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.