Model or dataset
rails/globalid avatar
rails/globalid

globalid: one URI that identifies any model in a Rails app

Identify app models with a URI

1,278 stars142 forksRubyMIT

At a glance

What is it?
The globalid gem gives a model instance an app-wide URI such as gid://app/Person/1, so a job, a URL or a select box can refer to a record without knowing its class. The current version adds signed identifiers with expiry and purpose, a locator that batches lookups, and a fetch that distinguishes a deleted record from a failing backend.
Who is it for?
Adopt globalid if you run Rails and need to pass a model reference across a job queue, a URL or a form without serialising the object, and you want the record to be located again later with the same semantics as the rest of the framework. Do not use a signed Global ID as a permanent identifier, because the expiry and the purpose binding are deliberate, and a signed value generated twice is not the same string.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 9 days ago.
What is it written in?
Mainly Ruby, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • rails/globalid1,278 ★ · +0
Star history
Repository2026-10-212026-11-07
rails/globalid1.3k1.3k

0 Stars in the last 17 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

A URI for something that is not a URL

A Global ID is an app-wide URI that uniquely identifies a model instance, and the shape is the one line everyone remembers:

code
gid://YourApp/Some::Model/id

The README says what that is for, and it is worth quoting the reasoning rather than the feature. This is helpful when you need a single identifier to reference different classes of objects.

The first example given is job scheduling. We need to reference a model object rather than serialize the object itself, and a Global ID can be used to locate the model when it is time to perform the job. The job scheduler does not need to know the details of model naming and IDs, just that it has a global identifier that references a model. That last sentence is the design constraint: the thing that carries the identifier must not have to understand the model layer.

The second example is a drop-down list of options consisting of both Users and Groups. Normally you would have to come up with your own ad hoc scheme to reference them, and with Global IDs there is a universal identifier that works for objects of both classes.

Both examples share a shape. A queue payload, a form field, a bookmark, a log line, anything that has to survive outside the request that created it and come back as a reference rather than as a value.

The gem is MIT licensed, part of the Rails organisation, and the last push was on 2026-10-12.

The mixin, and what locate does when it cannot help

Using the gem is one mixin. Mix GlobalID::Identification into any model with a .find(id) class method that returns an instance of the model, and a .where(id:) class method that returns an enumerable of instances. Support is automatically included in Active Record, so in a Rails application there is usually nothing to do.

ruby
person_gid = Person.find(1).to_global_id
# => #<GlobalID ...

person_gid.uri
# => #<URI ...

person_gid.to_s
# => "gid://app/Person/1"

GlobalID::Locator.locate person_gid
# => #<Person:0x007fae94bf6298 @id="1">

The behaviour of locate when it cannot find anything is stated in the README and it is a deliberate design choice. locate returns nil for a blank or unparseable Global ID, and it lets the backend's own exceptions bubble up when a record can't be found.

So there are three cases. A blank or malformed identifier gives you nil. An identifier that parses but whose record is gone gives you the exception from the model layer, for example a RecordNotFound from Active Record. And a backend that is merely broken gives you its own error, a connection failure, a timeout.

That is the difference between a missing object and an unavailable system, and it is why the next method exists.

fetch tells a deleted record from a broken backend

The fetch method exists because in a background worker those two failures need different handling.

ruby
GlobalID::Locator.fetch person_gid
# => #<Person:0x007fae94bf6298 @id="1">           # found
# => raises GlobalID::Locator::RecordNotFound     # the record no longer exists
# => raises GlobalID::Locator::RecordUnavailable  # the backend failed; retry may succeed

Both errors extend GlobalID::Locator::Error, so you can rescue either at once. And the README gives the case that motivates it: this is useful, for example, to discard a background job whose argument points at a deleted record, without also discarding jobs that hit a temporary database error.

That is a small API with a real operational consequence. A job queue that treats every lookup failure as a permanent error will eventually delete work that was merely unlucky, and a queue that retries everything will retry a job whose subject was deleted until it expires. fetch gives you the distinction in one call, with an error hierarchy that lets you write the rescue in one place and still tell the two apart.

For a library whose whole job is to look things up later, this is the kind of API that earns its keep in production rather than in a demo.

Signed identifiers, expiry and purpose

For added security Global IDs can also be signed to ensure that the data hasn't been tampered with.

ruby
person_sgid = Person.find(1).to_signed_global_id
# => #<SignedGlobalID:0x007fea1944b410>

person_sgid = Person.find(1).to_sgid
# => #<SignedGlobalID:0x007fea1944b410>

person_sgid.to_s
# => "BAhJIh5naWQ6Ly9pZGluYWlkaS9Vc2VyLzM5NTk5BjoGRVQ=--81d7358dd5ee2ca33189bb404592df5e8d11420e"

GlobalID::Locator.locate_signed person_sgid
# => #<Person:0x007fae94bf6298 @id="1">

The signed form is a self-contained string that carries the identifier, the purpose and the expiry, plus a signature that a tampered value fails. That is what makes it usable in a URL or an email, where an unsigned Global ID is just a guessable reference to whatever the identifier points at.

Expiry is the second half of the model. Signed Global IDs can expire sometime in the future, which the README says is useful if there is a resource people shouldn't have indefinite access to, like a share link.

ruby
expiring_sgid = Document.find(5).to_sgid(expires_in: 2.hours, for: 'sharing')

Inside two hours, locate_signed with the same purpose returns the document; more than two hours later it returns nil. In Rails an auto-expiry of one month is set by default, and you can change it in an initializer with Rails.application.config.global_id.expires_in = 3.months, or set a default SGID lifetime with SignedGlobalID.expires_in = 1.month.

There is a subtlety worth knowing. Expiring SGIDs are not idempotent, because they encode the current timestamp, so repeated calls to to_sgid produce different results and Document.find(5).to_sgid.to_s == Document.find(5).to_sgid.to_s is false. For a permanent value you pass expires_in: nil explicitly, and an explicit expires_at takes precedence over a relative expires_in.

The last feature is purpose. You can bind a signed ID to what it is for, so that evildoers cannot reuse a sign-up form's SGID on the login page, with to_sgid(for: 'signup_form') and the matching purpose required when locating it.

Batching lookups, and the query it saves

When you need to locate many Global IDs there is a batched path, GlobalID::Locator.locate_many, and locate_many_signed for signed ones, and the README is specific about the difference in behaviour.

The default locator passes every model_id per model_name, thus using model_name.where(id: model_ids) versus the model_name.find(id) of the single locate. In the case of looking up Global IDs from a database, it is only necessary to query once per model_name.

ruby
gids = users.concat(people).sort_by(&:id).map(&:to_global_id)

GlobalID::Locator.locate_many gids
# SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3)  [["id", 1], ["id", 2], ["id", 3]]
# SELECT "students".* FROM "students" WHERE "students"."id" IN ($1, $2, $3)  [["id", 1], ["id", 2], ["id", 3]]
# => [#<User id: 1>, [#<Student id: 1>], ...]

The important property, and the README says it explicitly, is that the order is maintained in the returned results. That matters when the identifiers came from a list you care about, and it means the batched call is a drop-in for a loop of single lookups, which is the migration path that matters when a page starts rendering hundreds of references.

The second point is the number of queries. A loop of locate calls issues one query per identifier; locate_many issues one query per model class. On a list of two hundred identifiers across three models that is the difference between six hundred round trips and three.

Options, and the eager loading that comes with them

Both locate and locate_many accept a hash of options as a second parameter, and the section lists the supported options.

The first is :includes, a Symbol, Array, Hash or a combination of them, with the same structure you would pass into an includes method of Active Record, and the documentation links to the Active Record guide on eager loading associations. If present, locate or locate_many will eager load all the relationships specified here.

The caveat at the end of the section is truncated in the source, and it is the kind of caveat that matters: it only works if all the GIDs models have the association. That is the natural limitation of a batched, cross-model eager load, and it is worth knowing before you rely on it for a list of mixed types.

There is also a repository-level detail that says something about how this gem is developed. The top-level entries include a globalid.gemspec, a Gemfile and Gemfile.lock, a Rakefile, a gemfiles/ directory, which is the conventional place for alternative dependency sets so the gem can be tested against several Rails versions, a lib/ directory and a test/ directory, plus a .devcontainer/ for a reproducible development environment.

The release history is the active part of the story. Version 1.2.1 came on 2025-09-17, 1.3.0 on 2025-09-23 and 1.4.0 on 2026-06-23, with the last push on 2026-10-12. The README's new material, the fetch semantics and the signed ID expiry behaviour, is the sort of thing that arrives in a minor version of a gem that has been part of Rails for a long time, and the one month default expiry with the purpose binding is a sensible answer to the problem of a signed identifier that never stops working.

Editorial conclusion

Adopt globalid if you run Rails and need to pass a model reference across a job queue, a URL or a form without serialising the object, and you want the record to be located again later with the same semantics as the rest of the framework. Do not use a signed Global ID as a permanent identifier, because the expiry and the purpose binding are deliberate, and a signed value generated twice is not the same string. Before you start, read the expiry defaults, since Rails sets a one month lifetime automatically, and use fetch rather than locate in a job worker so that a deleted record and a database outage are not handled the same way.

Frequently asked questions

What is a Global ID in Rails?

It is an app-wide URI that uniquely identifies a model instance, in the form gid://YourApp/Some::Model/id. It lets a job, a URL or a select box refer to a record of any class without knowing the model's naming or primary key details.

What is the difference between GlobalID::Locator.locate and fetch?

locate returns nil for a blank or unparseable Global ID and lets the backend's own exceptions bubble up. fetch raises GlobalID::Locator::RecordNotFound when the record is gone and RecordUnavailable when the backend failed, and both extend GlobalID::Locator::Error, so a worker can discard a job for a deleted record without discarding one that hit a temporary database error.

How do Signed Global IDs expire?

You pass expires_in for a relative lifetime or expires_at for an absolute time, and an explicit expires_at takes precedence. In Rails the default is one month, changeable with Rails.application.config.global_id.expires_in in an initializer or with SignedGlobalID.expires_in. Passing expires_in: nil produces a permanent value, and expiring IDs are not idempotent because they encode the current timestamp.

What is the purpose option for on a signed Global ID?

It binds the identifier to what it is for, so a value generated for a sign-up form cannot be replayed on the login page. You create it with to_sgid(for: 'signup_form') and must pass the same purpose when locating it.

How does locate_many differ from calling locate repeatedly?

The default locator passes every model_id per model_name, using model_name.where(id: model_ids) rather than model_name.find(id), so a batch of identifiers is one query per model class instead of one per identifier, and the order of the results is maintained.

Official sources

  1. Issues
  2. License: MIT
  3. rails/globalid on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/rails-globalid.svg)](https://hysenlabs.com/projects/rails-globalid)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/rails-globalid.svg)](https://hysenlabs.com/projects/rails-globalid)

Was this useful?