LaunchKit
← All posts
· 18 min read · by The LaunchKit team · 2 views

UUID primary keys in Rails

A UUID primary key used to be one decision and it is now two. The first is the old one, integers or UUIDs, and it has not changed: UUIDs cost storage and readability and buy you keys you can generate off-database, merge between databases, and hand to a client before the row exists. The second is new enough that most of the advice online predates it, and it is which UUID. Version 4 is 122 random bits and indexes like it. Version 7 puts a millisecond timestamp in the leading 48 and indexes like a sequence.

Rails has an opinion about the first question and no opinion at all about the second. Everything below was run against activerecord 8.1.3.1 and a local PostgreSQL 17.7, with the measurements repeated on a million rows.

What id: :uuid does in Rails 8.1

Four characters of configuration, and one line of framework behind them. In active_record/connection_adapters/postgresql/schema_definitions.rb:

def primary_key(name, type = :primary_key, **options)
  if type == :uuid
    options[:default] = options.fetch(:default, "gen_random_uuid()")
  end

  super
end

So create_table(:widgets, id: :uuid) produced, against PostgreSQL 17.7:

CREATE TABLE "widgets" ("id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
                        "name" character varying,
                        "created_at" timestamp(6) NOT NULL, "updated_at" timestamp(6) NOT NULL)

gen_random_uuid() has been in PostgreSQL core since 13 and needs no extension, which is why supports_pgcrypto_uuid? in the adapter is now vestigial: it is defined at postgresql_adapter.rb:455, it answers database_version >= 9_04_00, and nothing else in activerecord calls it.

The insert then relies on RETURNING, so the id comes back to Ruby without a second round trip:

Widget Create (1.1ms)  INSERT INTO "widgets" ("name", "created_at", "updated_at")
  VALUES ($1, $2, $3) RETURNING "id"

That gave 09458c83-c762-4047-a17a-3dc30427dee4, whose fifteenth character is 4. That character is the version nibble, and it is the whole subject of this post.

Grepping activerecord 8.1.3.1 and railties 8.1.3.1 for uuidv7 or uuid_v7 returns nothing. The same is true of rails/main today: the primary_key method above is byte for byte what ships. The framework's answer to "which UUID" is version 4, and it has not moved.

Why v4 and v7 are not the same key

Version 4 and version 7 are both 128 bits and both fit the same uuid column. The difference is what the first six bytes mean. In version 7 they are a Unix timestamp in milliseconds, big endian, followed by the version nibble, followed by randomness. So two v7 values generated a second apart sort in the order they were generated, using the ordinary uuid comparison operator, with no extra column.

You can read the clock back out with nothing but string slicing:

id = SecureRandom.uuid_v7
ms = id.delete("-")[0, 12].to_i(16)
Time.at(ms / 1000.0).utc.iso8601(3)
id: 01a0d3d0-b514-7c3f-881b-16fb25859bc6
created at: 2026-09-24T14:27:45.555Z
now:        2026-09-24T14:27:45.556Z

One millisecond of drift, which is the round trip through Time.at.

That ordering shows up in the planner's statistics before it shows up anywhere else. After loading a million rows into three tables and running ANALYZE, pg_stats.correlation for the id column read:

 tablename  | attname |  correlation
------------+---------+---------------
 t_bigint   | id      |             1
 t_v4       | id      | -0.0011063177
 t_v7spread | id      |     0.9999998

Correlation is how closely the physical order of rows on disk tracks the logical order of the column. A bigserial scores 1 by construction. A v7 key scores 0.9999998 without anyone declaring anything. A v4 key scores essentially zero, which is what "random" means when you say it to a query planner, and the planner uses that number to decide whether an index scan is worth the heap fetches it implies.

Index locality, measured

Here is the comparison that motivates all of this, on PostgreSQL 17.7, one million rows per table, primary key index created before the insert so that each key lands in a live B-tree rather than in a bulk build. The only variable is the order of the keys.

Two lanes showing where each kind of UUID key lands in a live B-tree and how the leaf page splits. A random version 4 key lands mid tree and splits a full leaf 50/50, leaving two half empty pages; a time ordered version 7 key always lands on the rightmost page, which splits 90/10 and leaves no gaps.

CREATE TABLE w1 (id uuid   PRIMARY KEY, body text NOT NULL);  -- v4, random
CREATE TABLE w2 (id uuid   PRIMARY KEY, body text NOT NULL);  -- v7, increasing
CREATE TABLE w3 (id bigint PRIMARY KEY, body text NOT NULL);  -- bigint, increasing

WAL generated by each insert, measured as a pg_current_wal_lsn() difference after a CHECKPOINT:

       k       |  wal
---------------+--------
 v4 random     | 170 MB
 v7 increasing | 156 MB
 bigint        | 140 MB

And the shape of the index afterwards, from pgstatindex:

 t  | avg_leaf_density | leaf_fragmentation |  sz
----+------------------+--------------------+-------
 w1 |            70.29 |              49.96 | 39 MB
 w2 |            90.03 |                  0 | 30 MB
 w3 |            90.06 |                  0 | 21 MB

Those three numbers are the argument. A random key arrives in the middle of the tree, splits a full leaf page down the middle, and leaves two pages half empty, which is why the steady state settles near 70% and why fragmentation sits at almost exactly half. An increasing key always arrives at the rightmost page, where PostgreSQL splits 90/10 instead, filling pages to 90% and leaving zero fragmentation. Version 7 gets the bigint's packing without being a bigint.

The read side is where it stops being an accounting exercise. Same query shape, same 1000 rows, on the v4 table and the v7 table after VACUUM ANALYZE:

 Limit (actual rows=1000 loops=1)
   Buffers: shared hit=87 read=920 written=758
   ->  Index Scan using t_v4_pkey on t_v4 (actual rows=1000 loops=1)
         Index Cond: (id > '6645edc5-110c-46d1-9180-fd24e2c1b097'::uuid)
 Limit (actual rows=1000 loops=1)
   Buffers: shared hit=10 read=5 written=4
   ->  Index Scan using t_v7spread_pkey on t_v7spread (actual rows=1000 loops=1)
         Index Cond: (id > '019b76a9-d401-7660-9d3e-ff85c21cfe89'::uuid)

1007 buffers against 15. The index is used in both, and the index is not the cost. Consecutive v4 keys point at a thousand heap pages scattered across 57 MB of table, so the scan touches one page per row. Consecutive v7 keys point at rows written at the same time, which are stored next to each other, so a thousand of them fit in five pages.

That is also the case for treating id as a cursor. Keyset pagination needs a column that is unique, indexed, ordered and static, which Pagination without a gem works through, and on a v7 primary key the id is all four without adding (published_at, id) to anything.

The benchmark that came out backwards

The obvious version of that test ran first and came out the other way. Three tables, DEFAULT gen_random_uuid() against DEFAULT uuidv7() using the SQL fallback from two sections below, a million rows into each, same comparison. Version 7 lost.

 t     | avg_leaf_density | leaf_fragmentation | index_size
-------+------------------+--------------------+-----------
 t_v4  |            71.65 |              50    | 38 MB
 t_v7  |            68.30 |              35.03 | 40 MB

A worse density and a bigger index than the random key it was supposed to beat. Two things caused it, and both are worth knowing before you benchmark this yourself.

The first is that a million rows inserted from generate_series finish in about two seconds, so the 48-bit millisecond prefix takes only about 2000 distinct values across the whole load. Roughly 500 rows share each one. Within a prefix the remaining bits are random, so those 500 land in random order, and the tree spends the entire load doing middle-of-page splits in a sliding window instead of appending. The second is that the fallback function has no sub-millisecond ordering at all, so there is nothing to break the ties.

Real traffic does not look like that. The fixed version of the test spread one row per millisecond over 1,000,000 milliseconds, which is 16 minutes of a moderately busy application, and produced the 90.03% that the previous section reports. But if you are about to measure this on synthetic data, you will measure the clock resolution rather than the key, and you will conclude that v7 is a pessimisation.

Getting uuidv7() out of PostgreSQL

PostgreSQL 18 was released on 2025-09-25 with uuidv7() in core. The documentation for it is specific about what goes into the value:

Generates a version 7 (time-ordered) UUID. The timestamp is computed using UNIX timestamp with millisecond precision + sub-millisecond timestamp + random.

That "sub-millisecond timestamp" is the part the hand-rolled versions leave out, and the previous section is what leaving it out costs. The same release also added uuidv4() as an explicit alias for gen_random_uuid(), plus uuid_extract_version() and uuid_extract_timestamp() for reading the two halves back.

Availability is no longer the obstacle it was a year ago. Heroku Postgres lists 18 as Available (Default) for newly provisioned databases, with 17 available until Q3 2028. Amazon RDS for PostgreSQL announced major version 18 on 2025-11-14, "starting with PostgreSQL version 18.1".

What is likely to be behind is the machine on your desk. Mine is 17.7, and the migration that assumes otherwise fails at CREATE TABLE:

ActiveRecord::StatementInvalid
PG::UndefinedFunction: ERROR:  function uuidv7() does not exist
LINE 1: CREATE TABLE "gadgets" ("id" uuid DEFAULT uuidv7() NOT NULL ...

On 18 the Rails side is one option. create_table(:gadgets, id: :uuid, default: -> { "uuidv7()" }) emits "id" uuid DEFAULT uuidv7() NOT NULL PRIMARY KEY, and the first insert returned 01a0d3ce-5943-71b4-8e21-a8f891495f44, version nibble 7. The schema dumper round-trips it as create_table "gadgets", id: :uuid, default: -> { "uuidv7()" }, force: :cascade, so db:schema:load reproduces it. Which also says that db:schema:load now needs PostgreSQL 18 everywhere the schema is loaded, and that includes CI.

The fallback function, and what it leaves out

Without 18, the most copied answer is a one-expression SQL function that splices a timestamp over the front of a gen_random_uuid(). It works, and it is worth running rather than trusting:

CREATE OR REPLACE FUNCTION uuidv7() RETURNS uuid AS $$
  SELECT encode(
    set_bit(
      set_bit(
        overlay(uuid_send(gen_random_uuid())
                placing substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
                from 1 for 6),
        52, 1),
      53, 1), 'hex')::uuid;
$$ LANGUAGE sql VOLATILE;

On 17.7 that produced 01a0d3cc-9555-7947-82dd-407ffe0fe5d3, with a version nibble of 7 and a variant nibble of 9, which is binary 1001 and therefore the RFC 9562 variant. Decoding the first 48 bits gave 2026-09-24 16:23:15.288+02 against a now() of 2026-09-24 16:23:15.288215+02.

What it does not do is order two calls inside the same millisecond, and the section above is what that is worth under load. pg_uuidv7, a C extension covering "Postgres 13 through 18", is the other route and its README is honest about where it now sits: "As of Postgres 18, there is a built in uuidv7() function, however it does not include all of the functionality below." A C extension needs filesystem access to the server, which on a managed database means it exists only if your provider already shipped it.

Generating the key in Ruby instead

The third route moves the problem out of the database entirely, and since Ruby 3.3 it needs no gem. SecureRandom.uuid_v7 is in securerandom 0.4.1, and its own documentation states the limitation before you hit it:

UUIDv7 has millisecond precision by default, so multiple UUIDs created within the same millisecond are not issued in monotonically increasing order.

and, further down:

Counters and other mechanisms for stronger guarantees of monotonicity are not implemented.

That is not a footnote. Two hundred rows created in a loop, each assigned self.id ||= SecureRandom.uuid_v7 in a before_create, came back out of the database in this order when sorted by id:

first 12 by id: [0, 2, 1, 3, 4, 6, 5, 7, 8, 9, 10, 12]
inversions: 60

Sixty of two hundred rows sorted ahead of a row created before them. Across 20,000 consecutive calls there were 9969 inversions, which is the coin flip you would expect between two values that agree on every ordered bit.

The fix is the keyword argument, which spends random bits on precision:

SecureRandom.uuid_v7, 20000 calls, inversions: 9969
extra_timestamp_bits: 12, 20000 calls, inversions: 0

Twelve is the ceiling, and anything past it raises ArgumentError: extra_timestamp_bits must be in 0..12. The documentation prices it: "Setting extra_timestamp_bits: 12 provides ~244ns of precision, but only 62 random bits (7.75 random bytes)." Sixty-two random bits is still far more than you need for a primary key and far less than you need to assume unguessability, which matters in the section after next.

Going this way means the column has no database default, and Rails will let you create a table that cannot accept a row:

create_table(:sprockets, id: :uuid, default: nil)
ActiveRecord::NotNullViolation
PG::NotNullViolation: ERROR:  null value in column "id" of relation "sprockets" violates not-null constraint
DETAIL:  Failing row contains (null, no id).

Every insert path now has to go through the callback. insert_all, upsert_all and anything written by psql or by another service do not, because callbacks are an Active Record feature and those are not Active Record.

Sixteen bytes, and everywhere they land

pg_column_size on 17.7: a uuid is 16 bytes, a bigint is 8, and the text form of a UUID is 40. The eight-byte difference is the whole of the storage story and it is not where the cost shows up.

Where it shows up is the count of columns holding one. A UUID primary key means every foreign key pointing at that table is also a UUID, and every index on those foreign keys carries 16-byte entries. A table with a primary key and three foreign keys, a million rows, nothing else:

uuid    89 MB
bigint  57 MB

56% more heap for the identical rows, before any index exists. Add the primary key indexes from earlier, 30 MB against 21 MB for the well-behaved v7 case, and the working set that has to fit in shared_buffers grows by roughly half across the whole schema.

The 40-byte text form is the one people forget, because it is not in the database. It is in every JSON response, every log line, every URL, every cache key, every job argument serialised into solid_queue_jobs. A background job taking ten record ids carries 400 bytes of identifier instead of about 70.

None of this is an argument against UUID keys. It is an argument against reaching for them on tables that will never leave the database, which in most applications is most tables.

The timestamp you did not mean to publish

Non-enumerability is the reason a lot of teams adopt UUID keys in the first place. /invoices/8412 tells a stranger that there are at least 8412 invoices and that 8411 probably exists; /invoices/09458c83-c762-4047-a17a-3dc30427dee4 tells them nothing.

Version 7 gives some of that back. The leading 48 bits are a plaintext timestamp, so anyone holding an id knows when the row was created, to the millisecond, using the three lines of Ruby near the top of this post. On PostgreSQL 18 it is one call to uuid_extract_timestamp().

Whether that matters is a product question rather than a security one, and it is usually answered by who holds the id. An internal order id, nobody cares. A signup id, and you have published the exact moment every account was created, which is enough to reconstruct a growth curve from a sample of public profile URLs. A password reset token, and the answer is that a UUID was never the right shape for that anyway, because 62 to 122 bits of randomness with a documented structure is not what you want guarding an account.

The other half is readability, and it is a smaller problem than it sounds until you are in it. A log line reading Widget Load ... WHERE "widgets"."id" = $1 [["id", "09458c83-c762-4047-a17a-3dc30427dee4"]] is fine to read and impossible to hold in your head while you grep for a second one. Version 7 helps here too, oddly: ids created near each other share a visible prefix, so 01a0d3ce groups a minute's worth of rows by eye. That is a real ergonomic difference from v4, where nothing is ever adjacent to anything.

Three things that break on the way in

A bad id does not raise. OID::Uuid#cast_value returns nil for anything failing its regex, and nil goes into the query as a literal null:

Widget.where(id: "banana").to_sql
# => SELECT "widgets".* FROM "widgets" WHERE "widgets"."id" = NULL

= NULL is never true, so the relation is empty and nothing anywhere says why. Widget.find("banana") does raise, with ActiveRecord::RecordNotFound: Couldn't find Widget with 'id'="banana", which reads as "no such row" rather than "that is not a UUID". A find_by or a where in a scope just returns nothing. The same regex is generous in the other direction, and all four of these cast to the same value: {6645edc5-110c-46d1-9180-fd24e2c1b097}, 6645edc5110c46d19180fd24e2c1b097, 6645EDC5-110C-46D1-9180-FD24E2C1B097 and 6645-edc5-110c-46d1-9180-fd24-e2c1-b097.

A t.references with no type: still builds a bigint, and PostgreSQL is the thing that notices:

PG::DatatypeMismatch: ERROR:  foreign key constraint "fk_rails_2fd19c0db7" cannot be implemented
DETAIL:  Key columns "post_id" and "id" are of incompatible types: bigint and uuid.

This one is loud, which makes it the best of the three. The generator answer is config.generators { |g| g.orm :active_record, primary_key_type: :uuid }, which rails/generators/active_record/migration.rb turns into both , id: :uuid on the table and , type: :uuid on the references, from two private methods four lines apart.

And your test data is a different UUID version from your production data:

ActiveRecord::FixtureSet.identify("alice", :uuid)
# => "f7d8be13-2a72-5104-bd02-7a5964737a91"

Version nibble 5. FixtureSet.identify at fixtures.rb:619 branches on the column type and calls Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s), which is deterministic, stable across platforms and exactly what fixtures need, as Rails fixtures vs factories gets into. It is also random with respect to the index. Any assertion you write about ordering by id, or any attempt to reproduce the locality numbers above in a test, will be measuring v5.

The call, and what would change it

Default to bigint. It is 8 bytes, it indexes at 90% density with no thought, it reads in a log, and its enumeration problem is solved by keeping primary keys out of URLs rather than by changing the key. Most tables never leave the database and have no reason to pay for a key that can.

Reach for UUIDs when the id has to exist before the insert does, when rows are created on more than one database that will later be merged, or when a client generates the id so a retry is idempotent. A sequence cannot do any of those.

When you do reach for them, use version 7, from uuidv7() if your PostgreSQL is 18. The locality numbers are the reason: 30 MB against 39 MB of index, 90% against 70% packing, 15 buffers against 1007 on a range read. The only remaining case for v4 as a primary key is a database older than 18, and Heroku's default plus RDS's November 2025 release make that a shrinking excuse.

If your PostgreSQL is 17 or below, generate in Ruby with SecureRandom.uuid_v7(extra_timestamp_bits: 12) rather than installing the SQL fallback. Both are stopgaps, the Ruby one is the one where the monotonicity gap is documented and fixable with a keyword argument, and it does not leave a function in your schema that PostgreSQL 18 will later shadow.

What would change this: a Rails release that made id: :uuid mean v7 when the adapter reports PostgreSQL 18, which is four lines in primary_key and would delete half this post. It has not happened, and rails/main still hardcodes gen_random_uuid().

The position has a cost. Choosing bigint by default means the day you do need a UUID key on an existing table, you are migrating a primary key and every foreign key pointing at it, live, which is a project rather than a migration. Teams who adopt UUIDs everywhere on day one are buying insurance against exactly that, and they are not wrong to. They are paying the premium on every table instead of on the ones that needed it.

What this post does not cover

The LaunchKit boilerplate uses bigint primary keys throughout. Its schema declares no uuid column and enables only pg_catalog.plpgsql, and the only place the word appears in its source is a comment in Onboarding::Schema noting that unmapped column types, "json, uuid, ...", fall back to a plain string text field. Nothing above is a feature of the product, which is the honest provenance for a post that could otherwise be read as a pitch.

Also absent: MySQL and SQLite, where the storage question is different because there is no native UUID type and the usual answer is a 16-byte binary column with its own byte-shuffling folklore; migrating an existing bigint table to UUID keys without downtime, which deserves its own post and a lot more care than a section; ULIDs and Snowflake ids, which solve the same ordering problem with different tradeoffs about length and coordination; and any timing figure, because buffer counts, page densities and byte sizes reproduce on your machine and milliseconds from mine would not.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.