Rails Encrypted Credentials: The Git Diff Feature You May Have Been Using Without Knowing

If you have been developing Rails applications for years, there’s a good chance you’ve used:

bin/rails credentials:edit

hundreds of times.

You probably know that Rails stores encrypted credentials in:

config/credentials.yml.enc

and keeps the encryption key separately in:

config/master.key

But did you know that Rails can make:

git diff

show the decrypted, human-readable changes to credentials.yml.enc?

I recently discovered this while working on a Rails 8.1.3.1 application and it was one of those:

“I’ve been using Rails every day for years, and I didn’t know Rails could do this!”

moments.

Let’s see how it works.


First: What is credentials.yml.enc?

Rails encrypted credentials allow us to keep secrets such as:

openai:
  api_key: ...

or:

aws:
  access_key_id: ...
  secret_access_key: ...

inside:

config/credentials.yml.enc

The file is encrypted.

The encryption key is stored separately in:

config/master.key

Rails documentation explicitly states that the encrypted credentials file can be stored in version control as long as the master key remains secure. (Ruby on Rails Guides)

So our repository can contain:

config/
├── credentials.yml.enc ← encrypted, safe to commit
└── master.key ← secret, NEVER commit

Editing Rails Credentials

Normally we edit credentials with:

bin/rails credentials:edit

Rails decrypts the credentials, opens them in your configured editor, and encrypts them again when you save.

Conceptually:

credentials.yml.enc
        │
        │ decrypt
        ▼
   Plain YAML
        │
        │ edit
        ▼
   Plain YAML
        │
        │ encrypt
        ▼
credentials.yml.enc

The plaintext credentials aren’t saved as a normal file.

But What Happens With git diff?

Here’s the interesting part.

If Git simply compared the encrypted files, we’d get something useless:

- 3d9Jx8...random-encrypted-data...
+ 7kP2mL...different-encrypted-data...

We wouldn’t know:

  • Which credential changed?
  • Was a key added?
  • Was a key removed?
  • Did the API key change?
  • Did somebody accidentally modify something?

This is where Rails’ credentials diff integration becomes useful.

Rails + Git textconv

Rails can configure Git to use a special diff driver:

[diff "rails_credentials"]
textconv = bin/rails credentials:diff

In my Rails 8.1 application, I found exactly this in:

.git/config

Git therefore doesn’t simply compare the encrypted contents.

Instead:

git diff
    │
    ▼
Git sees credentials.yml.enc
    │
    ▼
rails_credentials diff driver
    │
    ▼
bin/rails credentials:diff
    │
    ▼
Rails decrypts the credentials
    │
    ▼
Git displays a readable diff

Git itself doesn’t understand Rails encryption.

Rails is providing the text conversion command. Git simply knows how to invoke it.

See It Yourself

Suppose our credentials originally contain:

openai:
api_key: OLD_KEY

We change it to:

openai:
api_key: NEW_KEY

Now:

git diff

can show a useful diff such as:

+
+openai:
+ api_key: 'sdsdssdsdsdwewewddvcfgfgth'

That’s much more useful than comparing encrypted bytes.

The Experiment That Makes This Obvious

This is what made the behavior click for me.

Run:

git diff -- config/credentials.yml.enc

You get the human-readable credentials diff.

Now bypass Git’s text conversion:

git diff --no-textconv -- config/credentials.yml.enc

Now you see the encrypted content.

Something like:

3d9Jx8...encrypted-data...

That’s the proof.

The file itself is still encrypted.

It’s only the diff representation that’s being transformed.

So What Exactly Does Git Know?

Git doesn’t know anything about:

Rails
credentials
master.key
AES
encryption
decryption

Git knows:

diff driver
textconv

Rails configures:

rails_credentials

and tells Git:

When displaying a diff for this file,
run:
bin/rails credentials:diff

That’s a very nice example of two independent tools cooperating:

             Rails
               │
               │ provides
               ▼
       credentials:diff
               │
               ▼
             Git
               │
               │ uses
               ▼
           textconv

How Does Rails Configure It?

Rails provides:

bin/rails credentials:diff --enroll

This enrolls the project in credentials diffing.

The Git attributes include:

config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

Rails then ensures the Git diff driver is configured to use:

bin/rails credentials:diff

Rails’ application generator includes this credentials diff enrollment as part of application setup and Rails 7.0 already contained the credentials diffing implementation. (Gem)

So this isn’t actually an 8.1-only feature.

That’s an important distinction.

Is This New in Rails 8.1?

No – and this is an important correction.

The encrypted credentials diff functionality existed before Rails 8.1.

For example, Rails 7.0 already had the credentials:diff implementation, and Rails 7.2’s application generator also enrolled projects in credentials diffing. (Gem)

Rails has supported decrypted Git diffs for encrypted credentials for several versions and Rails 8.x continues to build on the credentials tooling.

Rails 8.1 does introduce other useful credentials functionality. For example, Rails 8.1 added command-line credential fetching, which can be useful for deployment tooling such as Kamal. (Ruby on Rails Guides)

Does This Make My Secrets Unsafe?

No – provided you protect the master key.

The important distinction is:

Git repository
│
├── credentials.yml.enc
│       ↓
│   encrypted
│
└── master.key
        ↓
     SECRET

The encrypted file can be committed.

The master key should not be committed. Rails’ security guide explicitly recommends keeping the master key safe and out of version control. (Ruby on Rails Guides)

One Thing to Remember

The decrypted content can appear in your local terminal output.

For example:

git diff

could display:

+
+openai:
+  api_key: 'sdsdssdsdsdwewewddvcfgfgth'

So don’t casually share terminal screenshots containing credential diffs.

Also be careful when copying terminal output into:

  • Slack
  • GitHub issues
  • Pull requests
  • screenshots
  • blog posts
  • AI assistants

NOTE: The encryption protects the file stored in Git, but a decrypted diff is plaintext.

Rails Developer Takeaway

There are three different things here:

1. Encrypted file

config/credentials.yml.enc

This is what is actually stored in Git.

2. Encryption key

config/master.key

This decrypts the credentials and must remain secret.

3. Git diff representation

bin/rails credentials:diff

This is what allows us to see meaningful changes locally.

So:

                 GitHub
                   │
                   │ encrypted
                   ▼
       credentials.yml.enc
                   ▲
                   │
             master.key
             stays secret


Local git diff:

credentials.yml.enc
        │
        ▼
credentials:diff
        │
        ▼
decrypted representation
        │
        ▼
human-readable diff

Try This Yourself

If you’re working on a Rails application, check:

git config --show-origin --get-regexp 'diff|textconv|filter'

You may find:

file:.git/config diff.rails_credentials.textconv bin/rails credentials:diff

Then:

git diff --no-textconv -- config/credentials.yml.enc

Compare that with:

git diff -- config/credentials.yml.enc

The difference is a great way to understand what’s really happening.

Quick Reference

# Edit credentials
bin/rails credentials:edit

# Enroll project in credential diffing
bin/rails credentials:diff --enroll

# Normal readable diff
git diff

# Show the actual encrypted file diff
git diff --no-textconv -- config/credentials.yml.enc

# Inspect Git's configuration
git config --show-origin --get-regexp 'diff|textconv|filter'

# Check Git attributes
git check-attr diff -- config/credentials.yml.enc

Security rule:

 Y config/credentials.yml.enc → commit it
 X config/master.key          → NEVER commit it


Rails’ official security guide confirms that encrypted credentials can be stored in version control while the master key must remain protected. (Ruby on Rails Guides)

📚 References

Happy Coding!

Ractors and Ruby Box in Ruby 4: What Do They Mean for Rails?

Ruby 4.0 introduced two fascinating runtime capabilities:

  • Ractors, significantly improved for parallel execution
  • Ruby Box, an experimental mechanism for isolating definitions inside one Ruby process

For a Rails developer, the obvious question is:

Can I take my existing Rails application and simply add Ractors and Ruby Box to make it faster or more scalable?

The answer is not yet that simple.

Ractors can be extremely useful for carefully isolated CPU-heavy work, but a conventional Rails application is deeply interconnected through global state, constants, classes, ActiveSupport, ActiveRecord, gems, configuration and caches.

Ruby Box is a completely different concept. It is not primarily a parallelism mechanism. It provides in-process isolation of definitions and loaded code, with potential applications such as running multiple application versions in one Ruby process. Ruby 4.0 documents it as experimental.

Let’s look at both from a Rails perspective.


1. First: what problem does a Ractor solve?

A normal Ruby thread looks roughly like this:

Rails process
├── Thread 1
├── Thread 2
├── Thread 3
└── Thread 4
└── same Ractor / same GVL

Threads within a Ractor still share that Ractor’s GVL, so they don’t execute Ruby code in parallel with one another.

Ractors change the model:

Rails process
├── Ractor A ── GVL ── Thread(s)
├── Ractor B ── GVL ── Thread(s)
└── Ractor C ── GVL ── Thread(s)

Different Ractors can execute Ruby code in parallel on different CPU cores. Ruby 4.0 also reduced internal contention and introduced Ractor::Port for communication.

That makes Ractors especially interesting for CPU-bound work.


2. What should NOT be your first Ractor experiment?

Suppose you have:

class ReportsController < ApplicationController
  def show
    @report = Report.generate
  end
end

It is tempting to write:

def show
  r = Ractor.new do
    Report.generate
  end

  @report = r.value
end

This is exactly the kind of approach that exposes the biggest problem.

A Rails application has a huge amount of shared framework state.

For example:

Rails
├── ActiveSupport
├── ActiveRecord
├── Zeitwerk
├── configuration
├── caches
├── logging
├── autoloading
├── class/module definitions
└── gems

Ractors deliberately restrict access to non-shareable objects across Ractors.

The Ruby documentation says that most objects are unshareable and communication between Ractors is intended to happen through shareable objects or message passing.

That makes a normal Rails application a poor candidate for simply wrapping arbitrary Rails calls inside Ractor.new.

There has also been a real Rails issue demonstrating Ractor::IsolationError when attempting to instantiate or use Rails application state from a non-main Ractor.


3. The better idea: use Ractors around isolated computation

Instead of:

Ractor
Entire Rails application

think:

Rails
├── request
├── database work
└── isolated CPU calculation
Ractor

For example, imagine a report containing millions of values.

class ReportCalculator
  def self.calculate(numbers)
    numbers.sum { |n| expensive_calculation(n) }
  end

  def self.expensive_calculation(n)
    # CPU-heavy calculation
    n ** 3
  end
end

You could partition the data:

chunks = numbers.each_slice(10_000).to_a

ractors = chunks.map do |chunk|
  Ractor.new(chunk) do |values|
    values.sum { |n| n ** 3 }
  end
end

result = ractors.sum(&:value)

The important architectural boundary is:

Rails
│ plain data
Ractor 1 ── CPU work ──┐
Ractor 2 ── CPU work ──┼──→ results
Ractor 3 ── CPU work ──┘
Rails

This is much more promising.

The Ractors don’t need to manipulate:

ActiveRecord::Relation
Rails.application
ActiveSupport::Cache
Controller
request
response

They receive isolated data and return isolated results.


4. A practical Rails use case: analytics

Imagine:

orders = Order
.where(created_at: 30.days.ago..)
.pluck(:amount)

The database query happens normally.

Then:

chunks = orders.each_slice(50_000).to_a

ractors = chunks.map do |chunk|
  Ractor.new(chunk) do
    {
      total: chunk.sum,
      average: chunk.sum.to_f / chunk.length
    }
  end
end

results = ractors.map(&:value)

total = results.sum { |r| r[:total] }

The database remains Rails’ responsibility.

The CPU-heavy aggregation becomes parallel work.

That is the mental model I’d recommend:

Use Rails for orchestration; use Ractors for isolated computation.

The above code can be Optimized. Check: https://railsdrop.com/optimization-fix-the-memory-heavy-ruby-operation/


5. Another good candidate: document/image processing

Suppose your application performs CPU-heavy transformations:

PDF
parse
transform
calculate
generate result

Instead of letting one Ruby execution stream process everything:

Rails
└── CPU-heavy processing

you can potentially build:

                    Rails
                      │
               Job / Service
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
       Ractor      Ractor      Ractor
         │           │           │
       file A      file B      file C
          └──────────┬──────────┘
                     ▼
                   result

The same principle applies to:

  • compression
  • large JSON transformations
  • encryption-related computation
  • parsing
  • ranking/scoring
  • simulations
  • large in-memory calculations

The exact benefit depends heavily on whether the work is CPU-bound and whether the cost of copying/moving data outweighs the parallelism benefit.

Ruby’s Ractor documentation explicitly notes that unshareable objects may be copied or moved between Ractors, so data-transfer overhead must be considered.


6. Ractor is not a replacement for ActiveJob

It is important not to confuse these abstractions.

For example:

SomeJob.perform_later(order.id)

and:

Ractor.new(...)

solve different problems.

ActiveJob/Sidekiq/GoodJob/etc. solve background job execution and process-level application architecture.

Ractors solve parallel execution inside one Ruby process.

You could potentially combine them:

Rails
Background Job
Ruby Process
├── Ractor 1
├── Ractor 2
├── Ractor 3
└── Ractor 4

But this is an advanced optimization, not the default architecture.


7. So where does Ruby Box fit?

Ruby Box is much less about CPU parallelism.

Its purpose is definition isolation.

Suppose you have:

class User
def role
"admin"
end
end

Now imagine loading another piece of code that reopens User:

class User
def role
"guest"
end
end

Normally, that’s a global change to the Ruby process.

Ruby Box lets those definitions exist in separate boxes.

Conceptually:

Ruby process
├── Main box
│ └── User#role → "admin"
└── Box B
└── User#role → "guest"

Ruby’s documentation describes this as isolation of class/module definitions, monkey patches, constants, global/class variables and loaded Ruby/native libraries.

This is a very different problem from Ractors.


8. A simple Ruby Box example

Ruby Box must be enabled at process startup:

RUBY_BOX=1 ruby app.rb

Setting the variable after Ruby has already started does not enable it.

Then:

box = Ruby::Box.new
box.require("./legacy_user.rb")

Suppose legacy_user.rb contains:

class User
def role
"legacy"
end
end

The definition is loaded into the box.

Conceptually:

Main box
└── User
Legacy box
└── User
└── role → "legacy"

The definition in the box is isolated from the corresponding definition in other boxes. Ruby’s documentation demonstrates this with constants, classes and methods.


9. This has a fascinating Rails use case: blue-green application versions

This is one of the use cases Ruby itself proposes.

Imagine:

One Ruby process
┌─────────────────────────────┐
│ Ruby Process │
│ │
│ Box A │
│ Rails App v1 │
│ │
│ Box B │
│ Rails App v2 │
└─────────────────────────────┘

Ruby 4.0 explicitly lists running web-app boxes in parallel as a potential blue-green deployment use case.

Theoretically, this gives you the ability to have:

/app-v1
/app-v2

loaded in separate definition environments inside the same Ruby process.

Then requests could be directed to:

traffic
├──→ Box A
└──→ Box B

This could eventually enable interesting deployment and migration strategies.

But there is a huge caveat.


10. Ruby Box is experimental

This is not currently something I’d take into a normal Rails production deployment simply because Ruby 4 has it.

The official documentation lists known issues, including:

  • native extension installation problems
  • require 'active_support/core_ext' potentially failing
  • limitations around methods defined in a box being called by built-in Ruby methods

and other TODO items.

That is especially important for Rails.

Rails isn’t a small collection of isolated classes.

It has a large dependency graph:

Rails
├── ActiveSupport
├── ActiveRecord
├── ActionPack
├── Zeitwerk
├── Rack
├── Bundler
├── native extensions
└── hundreds of possible gems

Isolating an entire Rails application therefore involves considerably more than:

box = Ruby::Box.new
box.require("app")

11. Ruby Box could be more interesting for development and testing first

One of Ruby’s proposed Ruby Box use cases is isolating tests that perform monkey patches.

Consider:

class String
def special
"patched"
end
end

Normally this contaminates the process.

A box can potentially isolate that modification:

Test process
├── Main box
│ └── normal String
├── Test Box A
│ └── patched String
└── Test Box B
└── different String definition

Ruby itself lists isolated test execution as an expected use case.

For a large test suite, this is an interesting direction.


12. How I would use Ractors in a new Rails application

I wouldn’t architect the entire application around Ractors.

Instead:

                    Rails
                      │
      ┌───────────────┼────────────────┐
      │               │                │
   HTTP/API        ActiveRecord     Background Jobs
      │
      │
      └────── CPU-heavy service ───────┐
                                       │
                          ┌────────────┼────────────┐
                          ▼            ▼            ▼
                       Ractor       Ractor       Ractor

Keep Rails state out of the Ractors wherever possible.

Design explicit boundaries:

input = {
values: values,
options: options
}

rather than:

ractor = Ractor.new do
Order.where(...)
end

The first is an isolated computation.

The second makes the Ractor responsible for Rails state.

That’s where the complexity explodes.


13. How I would introduce Ractors into an existing Rails app

Start with one measurable CPU bottleneck.

For example:

Before
request
large calculation
1 CPU core
response

Then extract:

class PricingCalculator
def self.calculate(input)
# pure Ruby calculation
end
end

Make it as pure as possible:

result = PricingCalculator.calculate(
prices: prices,
rules: rules
)

Then experiment with:

Ractor.new(input) do |data|
PricingCalculator.calculate(data)
end

Benchmark both:

single-threaded
vs
multiple Ractors

Don’t assume parallelism automatically means faster execution.

You need to measure:

  • CPU time
  • wall-clock time
  • memory usage
  • object copying
  • Ractor startup
  • throughput
  • latency

14. Rails architecture: where each feature fits

A useful mental model is:

                    Rails
                      │
     ┌────────────────┼─────────────────┐
     │                │                 │
     ▼                ▼                 ▼
   Web              Data             Jobs
     │                │                 │
     └────────────────┴────────┐        │
                               ▼        ▼
                       Application services
                               │
                       CPU-heavy workload
                               │
                         ┌─────┴─────┐
                         ▼           ▼
                      Ractor      Ractor

Ruby Box sits at a different architectural layer:

Ruby Process
├── Main / Application Box
├── Application Box A
└── Application Box B

So:

Ractor = parallel execution

while:

Ruby Box = definition/environment isolation

They solve different problems.


15. The big Rails limitation today

This is the part worth remembering.

A conventional Rails application is built around a substantial amount of shared application state.

That doesn’t fit naturally with Ractor’s isolation model.

There has been an explicit Rails issue requesting Ractor support and that issue was closed as “not planned.” The discussion showed Ractor::IsolationError arising from Rails class-level state.

https://github.com/rails/rails/issues/51543

That doesn’t mean Rails can never become Ractor-friendly.

It means:

Don’t interpret Ruby 4’s Ractor improvements as “Rails is now automatically Ractor-safe.”

Those are two different layers.

Ruby’s runtime may support the concurrency primitive while the framework ecosystem still has architectural work to do.

Read for more info: https://discuss.rubyonrails.org/t/ractor-safe-rails/91277


16. The practical strategy for a Rails developer

For an existing Rails application:

1. Find CPU-bound code
2. Extract it from Rails state
3. Make inputs/outputs explicit
4. Benchmark it
5. Try Ractors
6. Measure copying + memory
7. Keep the rest of Rails unchanged

For a new application:

Rails
thin controllers
application services
pure computation
Ractor boundary

That architecture gives you a much better chance of benefiting from parallel Ruby.

For Ruby Box:

Development/testing first
isolated definitions
experimentation
specialized deployment scenarios

rather than immediately attempting:

"Let's run the whole Rails app in 10 Ruby Boxes."

Final takeaway

Ruby 4 did something more interesting than simply making threads faster.

It is giving Ruby developers more explicit runtime tools:

Ractor
parallel Ruby computation
Ruby Box
isolated Ruby definitions
YJIT / ZJIT
faster execution
GC/runtime improvements
less overhead

For Rails, however, the winning strategy isn’t:

“Convert Rails to Ractors.”

It is:

“Keep Rails responsible for application orchestration and isolate carefully chosen CPU-heavy computations behind Ractor boundaries.”

And Ruby Box is even more experimental.

Its long-term Rails potential may actually be more architectural than performance-oriented: isolating application versions, tests, plugins, or dependency environments inside one Ruby process.

The exciting thing is that Ruby 4 gives us primitives that make these designs possible.

The engineering challenge is deciding where the boundary belongs.

That is ultimately the same lesson we’ve been following through this series:

Ruby gives us abstractions. Understanding the runtime lets us decide when to cross them.

A useful next post in this series would be “Ractor vs Thread vs Process in Rails: when should a senior Rails developer choose each?” – with real benchmarks, memory/CPU trade-offs and a Sidekiq/Puma/Ractor architecture comparison.

Ruby Beyond CRuby: Why JRuby and TruffleRuby Exist? What Ruby 4.0 Really Changed

In my previous posts, I looked at how Ruby code eventually reaches the VM, native runtime and CPU.

That naturally leads to another question:

Why is there more than one Ruby?

Most Ruby developers use CRuby/MRI and may never think about it. But Ruby is a language specification, not a single runtime implementation.

Today we have several implementations, with two particularly interesting alternatives:

JRuby – Ruby implemented on the JVM.

TruffleRuby – Ruby implemented using the GraalVM/Truffle ecosystem.

And then there is the increasingly interesting question:

Did Ruby 4 finally remove the GIL and solve Ruby’s performance/scalability problems?

Not exactly.

Let’s look at why these implementations exist and where Ruby 4.0 stands today.


Ruby is a language, not necessarily an implementation

When I write:

class User
  def greet
    "Hello"
  end
end

I’m writing Ruby language semantics.

But somebody has to implement those semantics.

There is no requirement that the implementation must be written in C.

So we can have:

                    Ruby Language
                         │
          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
       CRuby           JRuby       TruffleRuby
          │              │              │
          ▼              ▼              ▼
       C / VM           JVM       GraalVM / Truffle

All three attempt to behave like Ruby while using very different execution technologies.


Why was JRuby created?

JRuby’s fundamental idea was:

What if Ruby could run on the JVM and take advantage of everything the JVM already provides?

The JVM already has:

  • mature garbage collection
  • JIT compilation
  • highly optimized threading
  • profiling
  • excellent runtime tooling
  • enormous Java libraries
  • mature production infrastructure

Instead of building all of that from scratch, JRuby puts a Ruby implementation on top of the JVM.

Ruby code
    │
    ▼
JRuby
    │
    ▼
JVM
    │
    ├── JIT
    ├── GC
    ├── Threads
    └── Java libraries
    │
    ▼
CPU

JRuby explicitly aims to provide Ruby without a global interpreter lock, true parallelism and integration with Java. (GitHub)

That makes JRuby particularly interesting for applications where Ruby needs to coexist with Java infrastructure.


JRuby’s biggest advantage: true parallel Ruby threads

In CRuby, ordinary Ruby threads are native threads, but Ruby execution within a single Ractor is constrained by its GVL.

JRuby takes a different approach.

Multiple Ruby threads can execute Ruby code concurrently because there is no equivalent global interpreter lock preventing Ruby threads from running in parallel.

Conceptually:

CRuby

Thread 1 ──┐
Thread 2 ──┼──→ GVL ──→ Ruby execution
Thread 3 ──┘

Whereas:

JRuby

Thread 1 ─────────────→ CPU Core 1
Thread 2 ─────────────→ CPU Core 2
Thread 3 ─────────────→ CPU Core 3

That can be extremely valuable for CPU-heavy or highly concurrent workloads.

JRuby 10 also moved to Java 21 and made invokedynamic optimization the default, taking advantage of more modern JVM capabilities. (blog.jruby.org)


Why TruffleRuby?

TruffleRuby comes from a completely different idea.

Instead of saying:

“Let’s implement Ruby using the JVM.”

the Truffle approach essentially says:

“Let’s implement Ruby on a framework designed to build highly optimizing language runtimes.”

TruffleRuby uses the Truffle framework and GraalVM.

Ruby source
     │
     ▼
TruffleRuby
     │
     ▼
Truffle AST / runtime
     │
     ▼
Graal compiler
     │
     ▼
Optimized machine code
     │
     ▼
CPU

The interesting part is that Truffle/Graal can observe running code and aggressively specialize and optimize it.

TruffleRuby’s project explicitly targets high performance for Ruby workloads, parallel execution without a global interpreter lock, native extensions and interoperability with Java and other languages in the GraalVM ecosystem. (GitHub)

GraalVM Doc: https://www.graalvm.org/latest/introduction/


TruffleRuby and JRuby solve a similar problem differently

This distinction is important.

CRubyJRubyTruffleRuby
Main technologyC + Ruby VMJVMTruffle + GraalVM
GVL for normal Ruby threadsYesNoNo
Parallel Ruby threadsLimited by GVLYesYes
JVM ecosystemNoExcellentExcellent
JITYJIT/ZJITJVM JITGraal
Native extensionsExcellentDifferent approachMany C extensions supported
StartupExcellentGenerally slowerDepends on configuration
Warm-upLowHigherHigher
Peak performanceVery goodVery goodExcellent for suitable workloads

The important lesson is:

There isn’t one universally “best Ruby”.

The optimal runtime depends on the workload.


Now the big question: Does Ruby 4 remove the GIL?

No.

And there is an important terminology correction.

CRuby generally calls it the GVL – Global VM Lock.

Ruby 4.0 did not remove it from normal Ruby threads.

Ruby’s documentation states that threads within the same Ractor share a ractor-wide GVL and therefore cannot execute Ruby code in parallel with each other. Threads belonging to different Ractors can execute in parallel.

Ractors are designed to provide parallel execution of Ruby code without thread-safety concerns. (Ruby Documentation)

So:

                    CRuby 4.0
                       │
          ┌────────────┴────────────┐
          ▼                         ▼
      Ractor A                  Ractor B
          │                         │
     Thread 1                   Thread 1
     Thread 2                   Thread 2
          │                         │
        one GVL                  one GVL
          │                         │
          └──────────┬──────────────┘
                     │
               parallel execution

This is a major distinction.

Ruby 4 did not say:

“GVL is gone.”

It moved Ruby’s concurrency model further toward Ractor-based parallelism.


Ruby 4.0 significantly improved Ractors

Ruby 4.0 invested heavily in reducing the contention that previously limited Ractor scalability.

The release notes specifically mention improvements such as:

  • lock-free structures for frozen strings and the symbol table
  • fewer locks in method-cache lookups
  • faster instance-variable access
  • reduced allocation contention
  • reduced CPU cache contention
  • fewer locks around object_id
  • fixes for deadlocks and GC races involving Ractors (Ruby)

This is a much deeper improvement than simply deleting one lock.

The architecture is moving toward:

Before

Ractor ──┐
Ractor ──┼── shared internal state ── contention
Ractor ──┘


Ruby 4 direction

Ractor A ── mostly independent state
Ractor B ── mostly independent state
Ractor C ── mostly independent state

             ↓

       less lock contention
       less cache contention
       better parallelism

Ruby 4 also introduced Ractor::Port as a new synchronization mechanism and added shareable Proc/lambda APIs.


Ruby 4’s bigger performance story: YJIT and ZJIT

Ruby 4.0 introduced ZJIT, the next-generation JIT compiler after YJIT.

The interesting part is that Ruby now has two very different JIT stories:

                 Ruby 4
                   │
          ┌────────┴────────┐
          ▼                 ▼
        YJIT               ZJIT
      mature              new
      production          experimental
          │                 │
          ▼                 ▼
    native machine code   native code

Ruby’s own release announcement is very clear:

ZJIT is faster than the interpreter, but not yet as fast as YJIT.

Ruby 4.0 therefore recommends experimentation rather than production deployment for ZJIT.

ZJIT is intended to raise Ruby’s performance ceiling through larger compilation units and SSA-based intermediate representation, while also making the compiler architecture more approachable for outside contributors.

So Ruby 4 did not replace YJIT with a magically faster JIT overnight.

It started building the next generation.


Ruby 4 also improved the GC and object system

Some of the most interesting Ruby 4 changes aren’t visible from Ruby syntax at all.

Ruby 4.0 includes improvements such as:

• Independent growth of GC heaps for different size pools
• Faster sweeping of pages containing large objects
• Faster Class#new
• Improved instance-variable storage
• Less GC overhead from write barriers
• Better handling of embedded large Bignums
• Faster object_id/hash operations

These changes target allocation, memory consumption, GC work, object access and general runtime overhead. (Ruby)

For a Rails application, these details matter because a significant amount of application work eventually becomes:

allocate
object lives
object becomes unreachable
GC
CPU + memory bandwidth

Improving that pipeline can produce real application-level benefits without changing your Rails code.


Ruby 4’s interesting new feature: Ruby Box

Ruby 4.0 also introduced an experimental feature called Ruby Box.

It allows definitions and changes to be isolated from other boxes.

That includes things like:

  • monkey patches
  • class/module definitions
  • class/global variables
  • loaded libraries

One proposed use case is running multiple isolated application versions in the same Ruby process – for example, blue/green deployment scenarios.

Conceptually:

Ruby Process
├── Box A → Application version A
├── Box B → Application version B
└── Box C → Experiment

This is quite different from the normal Ruby process model and could become more interesting over time.


So did Ruby 4 “fix Ruby performance”?

No single release can be described that way.

Ruby’s performance problem has never been just one problem.

There are several:

Ruby performance
├── Interpreter overhead
├── Method dispatch
├── Object allocation
├── Garbage collection
├── Memory/cache behaviour
├── JIT compilation
├── Lock contention
└── Parallel execution

Ruby 4 improves several of these.

But each improvement has trade-offs.


Ruby 4: the best features

For an experienced Ruby/Rails developer, I would highlight these:

1. Better parallelism

Ractors are substantially more mature and have significantly less internal contention. (Ruby)

2. Better JIT direction

YJIT remains the mature choice, while ZJIT establishes a new JIT architecture with a higher long-term performance goal.

3. Runtime and GC improvements

Allocation, sweeping, object access and GC overhead have all received attention.

4. Ruby Box

A fascinating new isolation primitive that may eventually influence how long-running Ruby processes host multiple isolated applications.

5. Ecosystem maturity

Ruby 4 continues to preserve the programming model that makes Rails productive while the runtime underneath becomes increasingly sophisticated.


But Ruby 4 still has limitations

The biggest one is straightforward:

Normal Ruby threads still don’t provide unrestricted CPU parallelism inside one Ractor.

The GVL remains part of CRuby’s threading model. (Ruby Documentation)

There are also practical considerations around Ractors: code must respect Ractor isolation and shareability rules and not every gem or application architecture will naturally benefit from them.

And ZJIT is not yet a drop-in reason to turn off YJIT and deploy it everywhere; Ruby 4.0’s own release notes explicitly say it is not yet as fast as YJIT and recommend holding off on production use.


What about Ruby 4 vs JRuby and TruffleRuby?

This is where Ruby becomes particularly interesting.

                       Ruby
                        │
       ┌────────────────┼────────────────┐
       │                │                │
       ▼                ▼                ▼
     CRuby             JRuby        TruffleRuby
       │                │                │
       ▼                ▼                ▼
     C/VM              JVM        Graal/Truffle
       │                │                │
       ▼                ▼                ▼
     YJIT             JVM JIT        Graal JIT
       │                │                │
       ▼                ▼                ▼
    Ractors         real threads    real threads

CRuby’s advantage is its enormous compatibility, mature ecosystem, excellent startup characteristics and continued optimization of the standard implementation.

JRuby’s strength is the JVM: parallel Ruby threads and access to the Java ecosystem.

TruffleRuby’s strength is aggressive specialization and Graal-based optimization, with parallel Ruby execution and polyglot capabilities. Its maintainers report very high performance on appropriate benchmark workloads, though warm-up and compatibility remain practical considerations.


My conclusion as a Ruby developer

I think the most important change is not:

“Ruby 4 removed the GIL.”

It didn’t.

The more accurate statement is:

Ruby is steadily evolving from a primarily interpreter-centric runtime toward a highly optimized, JIT-driven, increasingly parallel execution platform.

The interesting evolution looks like this:

Old Ruby
   │
   ▼
Interpreter
   │
   ▼
GVL
   │
   ▼
Threads mostly for concurrency


Modern Ruby
   │
   ├── YJIT
   ├── ZJIT
   ├── better GC
   ├── better object representation
   ├── reduced lock contention
   └── Ractors
           │
           ▼
      parallel Ruby

And this is exactly why learning C and runtime internals is becoming more valuable.

When you understand memory, object allocation, GC, locks, CPU caches, JITs, threads and process boundaries, Ruby 4’s changes stop looking like a collection of release notes.

You start seeing the bigger picture:

The Ruby language hasn’t changed its philosophy of developer productivity. The runtime underneath it is becoming increasingly sophisticated at extracting performance from that high-level language.

As of August 2026, the current stable Ruby 4 branch is Ruby 4.0, with Ruby 4.0.6 released on July 14, 2026. (Ruby)

And that makes this a perfect point in the series to go one level deeper:

What actually happens inside a Ractor, how its GVL differs from the old “global” model and how Ruby can execute Ruby code in parallel without simply removing thread safety?

Happy Rubying!

What Really Happens When Ruby Code Executes?

As Ruby developers, we normally think execution is simple:

ruby app.rb

Ruby runs the file.

But what exactly is ruby?

Does the CPU execute Ruby code directly?

What is the Ruby interpreter?

Where does bytecode come into the picture?

What exactly is the runtime?

And where do C, machine code and the operating system enter the story?

For a developer who wants to understand Ruby beyond the language syntax, these are important questions.

This article follows a small Ruby program from source code all the way down to CPU execution.

Note: The discussion here focuses on CRuby/MRI- the standard Ruby implementation. Details differ in JRuby, TruffleRuby and other implementations. Ruby’s RubyVM APIs are explicitly MRI-specific. (docs.ruby-lang.org)


1. Start with a simple Ruby class

Consider this file:

# person.rb

class Person
  def initialize(name)
    @name = name
  end

  def greet
    "Hello, #{@name}"
  end
end

person = Person.new("Ruby")
puts person.greet

We execute it:

ruby person.rb

So what happens after we press Enter?


2. ruby is an executable program

When we type:

ruby person.rb

the shell does not understand Ruby syntax.

It finds the ruby executable in your PATH.

For example:

which ruby

might return:

/usr/bin/ruby

or perhaps a version-manager path such as:

/Users/me/.rbenv/shims/ruby

That executable is a compiled native program.

This is a crucial distinction:

Ruby source code is not itself executed by the operating system. The operating system starts the Ruby executable, and that program executes your Ruby program.

The flow initially looks like this:

Terminal
   │
   │ ruby person.rb
   ▼
Shell
   │
   │ locate executable
   ▼
Ruby executable
   │
   ▼
Operating System creates process

The ruby process is now running.


3. The Ruby interpreter is inside that process

People often say:

“Ruby interprets my code.”

This is useful shorthand, but the reality is more interesting.

The Ruby executable contains the runtime machinery necessary to:

  • read Ruby source
  • parse it
  • compile it
  • create internal structures
  • execute VM instructions
  • manage Ruby objects
  • run garbage collection
  • perform method calls
  • interact with the operating system

So we can think of:

ruby executable
       │
       ├── parser
       ├── compiler
       ├── VM
       ├── garbage collector
       ├── object system
       └── runtime libraries

This collection of mechanisms is what we generally mean by the Ruby runtime.


4. Source code is first parsed

Our source:

person = Person.new("Ruby")

is not immediately converted into CPU instructions.

Ruby first needs to understand its structure.

The parser turns the source into an internal representation of the program.

Conceptually:

Ruby source
    │
    ▼
Tokenizer / Parser
    │
    ▼
Internal syntax representation

For example, Ruby has to understand:

Person.new("Ruby")

as roughly:

receiver: Person
method:    new
argument:  "Ruby"

The exact internal representation is an implementation detail, but the important point is:

Ruby must understand the program before it can execute it.


5. Ruby then compiles the code into VM instructions

This is the part many Ruby developers don’t realize.

CRuby does not normally execute the original Ruby source line-by-line.

The code is compiled into instructions for Ruby’s virtual machine.

These are commonly referred to as YARV instructions or Ruby bytecode.

Ruby exposes this machinery through:

RubyVM::InstructionSequence

For example:

puts RubyVM::InstructionSequence.compile(
  'puts "Hello"'
).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,12)>
0000 putself                                                          (   1)[Li]
0001 putchilledstring                       "Hello"
0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
0005 leave
=> nil

You will see VM instructions rather than Ruby source.

The exact output changes between Ruby versions because the instruction set and compiler details are implementation-specific. Ruby documents InstructionSequence specifically as a way to inspect the VM’s compiled instructions.

So our pipeline becomes:

person.rb
   │
   ▼
Parser
   │
   ▼
Ruby internal representation
   │
   ▼
Compiler
   │
   ▼
YARV bytecode / InstructionSequence

6. What is bytecode?

Bytecode is an intermediate instruction format designed for a virtual machine.

It is not CPU machine code.

Think of this distinction:

Ruby source
    ↓
Ruby VM bytecode
    ↓
CPU machine code

Bytecode might conceptually contain operations such as:

putself
putobject
send
setlocal
getinstancevariable
leave

These aren’t x86 instructions.

They are instructions understood by the Ruby VM.

Ruby’s documentation exposes the compiled instruction sequence and its bytecode specifically for inspecting how YARV works. (docs.ruby-lang.org)


7. Enter the virtual machine

Now we have something like:

Ruby source
     ↓
Compiler
     ↓
YARV bytecode
     ↓
Ruby VM

The VM executes those instructions.

You can think of it as a machine built inside the Ruby process:

             Ruby Process
┌──────────────────────────────────────┐
│                                      │
│   Ruby VM                            │
│                                      │
│   ┌──────────────────────────────┐   │
│   │ YARV instructions             │   │
│   │                              │   │
│   │ putobject                    │   │
│   │ send                         │   │
│   │ getinstancevariable          │   │
│   │ leave                        │   │
│   └──────────────┬───────────────┘   │
│                  │                   │
│                  ▼                   │
│             VM execution             │
│                                      │
└──────────────────────────────────────┘

CRuby’s interpreter loop and instruction definitions are implemented in the Ruby source tree; the Ruby documentation points to insns.def and vm_exec.c as core pieces of this machinery. (docs.ruby-lang.org)

https://github.com/ruby/ruby/blob/master/vm_exec.c


8. But the VM itself is native code

Here is the important connection to C.

The Ruby VM isn’t written in Ruby.

CRuby itself is implemented primarily in C, with some components implemented in other languages.

So conceptually:

Your Ruby code
      ↓
Ruby bytecode
      ↓
CRuby VM
      ↓
C code
      ↓
Machine instructions
      ↓
CPU

This is where learning C becomes incredibly useful for a Ruby developer.

Ruby is high-level.

The Ruby runtime is much closer to the machine.


9. What happens with our Person class?

Take:

class Person
  def greet
    "Hello, #{@name}"
  end
end

Ruby compiles the class and its methods into VM instruction sequences.

There isn’t simply one giant sequence representing the entire application.

Different constructs can have different instruction sequences.

Ruby’s InstructionSequence#type can identify sequences such as:

:class
:method
:block
:rescue
:ensure
:top

among others. (docs.ruby-lang.org)

Conceptually:

Person class
     │
     ├── class instruction sequence
     │
     ├── initialize method sequence
     │
     └── greet method sequence

When:

person.greet

executes, the VM needs to resolve the method call and execute the corresponding instruction sequence.


10. Method calls become VM work

This Ruby:

person.greet

looks tiny.

Internally, Ruby has to determine:

1. What object is `person`?
2. What class does it belong to?
3. Which method is `greet`?
4. Is the method overridden?
5. What arguments are involved?
6. What execution frame should be created?
7. Which instructions should run?

Conceptually:

person.greet
     │
     ▼
VM method dispatch
     │
     ▼
Find `greet`
     │
     ▼
Create/enter execution frame
     │
     ▼
Execute method instructions

The exact internals are sophisticated, including method caches and object-shape optimizations, but the important thing is that the VM – not your operating system- understands the Ruby method call.


11. Where does the operating system come in?

Eventually, everything has to reach the real machine.

The operating system created the Ruby process.

It provides things such as:

virtual memory
threads
file descriptors
sockets
timers
process scheduling
system calls

When Ruby needs to write:

puts "Hello"

the operation eventually crosses from Ruby runtime code into OS facilities for output.

Conceptually:

puts
 ↓
Ruby implementation
 ↓
C runtime / OS interface
 ↓
system call
 ↓
Operating System
 ↓
terminal / file / pipe

The exact path can vary by platform and implementation, but this is the important architectural boundary.


12. Where does the CPU actually execute instructions?

Here is the complete picture:

┌──────────────────────────────┐
│       Ruby Source            │
│                              │
│  person.greet                │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ Parser / Compiler             │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ YARV Bytecode                │
│ Ruby VM instructions         │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ CRuby VM                     │
│ Native runtime implementation│
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ Native Machine Instructions  │
│ x86-64 / ARM64 / etc.        │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ CPU                          │
└──────────────────────────────┘

That is the mental model I want to keep as a Ruby developer.


13. And then there is JIT

The previous diagram describes the interpreter path well, but modern Ruby can go further.

CRuby includes YJIT, a Just-In-Time compiler.

Instead of always executing VM bytecode through the interpreter, frequently executed code can be compiled into native machine code.

Conceptually:

             Ruby source
                  ↓
             VM bytecode
                  ↓
          ┌───────┴────────┐
          │                │
          ▼                ▼
     Interpreter         YJIT
          │                │
          ▼                ▼
      VM execution     Native code
          │                │
          └───────┬────────┘
                  ▼
                 CPU

YJIT became production-ready in Ruby 3.2, and Ruby’s documentation describes the interpreter and YJIT as different execution paths around the VM. (Ruby)

This is an important distinction:

Ruby bytecode is not necessarily the final form of execution.

Depending on how Ruby is running and whether JIT is enabled, execution can involve interpreted VM instructions, JIT-generated native code, or transitions between them.


14. Try it yourself

Check your Ruby implementation:

ruby -v

Check where the executable comes from:

which ruby

Inspect VM instructions:

ruby -e 'p RubyVM::InstructionSequence.compile("1 + 2").disasm'
"== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,5)>
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 putobject 2
0003 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0005 leave\n"

Try a method:

ruby -e '
class Person
  def greet
    "hello"
  end
end

puts RubyVM::InstructionSequence.compile(
  "Person.new.greet"
).disasm
'

You will see that Ruby source code has already been transformed into a lower-level instruction sequence before execution.

The exact instructions will depend on your Ruby version, so don’t treat a particular disassembly listing as universal. Ruby explicitly warns that instruction sequences are version-dependent. (docs.ruby-lang.org)


15. The complete mental model

As a senior Ruby developer, I find this model much more useful than simply saying “Ruby is interpreted.”

                   Ruby Program
                        │
                        ▼
                 Ruby Executable
                        │
                        ▼
                    Parser
                        │
                        ▼
                    Compiler
                        │
                        ▼
               YARV Bytecode
                        │
                        ▼
             ┌──────────────────┐
             │     CRuby VM      │
             └────────┬─────────┘
                      │
             ┌────────┴────────┐
             │                 │
             ▼                 ▼
        Interpreter          YJIT
             │                 │
             ▼                 ▼
       Native runtime     Native machine code
             │                 │
             └────────┬────────┘
                      ▼
                  CPU executes
                      │
                      ▼
               Memory / OS / I/O

So when I run:

ruby person.rb

the CPU isn’t magically executing Ruby syntax.

The operating system starts a native Ruby process.

That process parses my Ruby source, compiles it into VM instructions, and the CRuby runtime executes those instructions – potentially compiling hot code to native machine code through JIT.

And that brings us right back to why learning C is so valuable.

When you understand C, pointers, memory, functions, stacks, machine instructions and system calls, the Ruby runtime stops looking like a black box.

It becomes another program.

A very sophisticated program – but still a program running on a machine.

And that is exactly where I want to go next: inside the Ruby object model itself – VALUE, RBasic, object headers, heap allocation and how a simple Person.new becomes a real object in memory.

The natural next article is “What does Person.new actually create inside CRuby?” – connecting the Ruby object model to C structs, VALUE, object headers, heap slots and garbage collection.

Happy Rubying! ~

Ruby’s Mysterious Symbols: The Syntax Every Ruby Developer Should Truly Understand

Ruby is famous for making code expressive.

But that expressiveness comes with a side effect: Ruby contains quite a few symbols and syntax constructs that can look almost cryptic – even to experienced developers coming from other languages.

Consider this:

message = <<~TEXT
  Hello #{user.name},

  Your order has been shipped.

  Thanks!
TEXT

What exactly does <<~TEXT mean?

Or:

users.filter_map { _1.email if _1.active? }

What is _1?

Or:

case response
in { status: 200, body: String => body }
  puts body
end

Why does Ruby allow String =>>>> body inside a pattern?

These aren’t random pieces of syntax. They are examples of Ruby’s philosophy: make common programming operations concise without sacrificing readability.

This article explores some of Ruby 3.4’s most interesting “mysterious” syntax and more importantly explains what each construct means, why it exists, and when a senior developer should use it – or avoid it.


1. <<~ – The Squiggly Heredoc

Let’s start with one of the most useful Ruby syntax features.

message = <<~TEXT
  Hello World
    This is Ruby
  Goodbye
TEXT

The <<~ syntax is called a squiggly heredoc.

What is a heredoc?

A heredoc allows you to define a multiline string:

message = <<TEXT
Hello
World
TEXT

Ruby keeps the newlines inside the string.

The problem is indentation.

In real Ruby code, especially Rails applications, multiline strings are usually nested inside methods, classes, conditionals, etc.

Without squiggly heredoc:

def email_body
  <<TEXT
Hello,
Welcome to our application.
Thank you.
TEXT
end

The heredoc terminator often needs awkward indentation.

<<~ solves that

def email_body
  <<~TEXT
    Hello,
    Welcome to our application.
    Thank you.
  TEXT
end

Ruby removes the common leading indentation.

Conceptually:

source indentation
        ↓
    Hello
    Welcome
    Thank you

becomes:

Hello
Welcome
Thank you

Why is this useful in Rails?

Extremely useful for SQL:

sql = <<~SQL
  SELECT users.*
  FROM users
  INNER JOIN orders ON orders.user_id = users.id
  WHERE users.active = TRUE
SQL

Or HTML:

html = <<~HTML
  <div class="user">
    <h2>#{user.name}</h2>
  </div>
HTML

Or shell commands:

command = <<~BASH
  echo "Starting deployment"
  bundle exec rails db:migrate
  echo "Deployment complete"
BASH

The senior-level takeaway

<<~ isn’t merely a formatting convenience.

It lets the Ruby source code remain properly indented without contaminating the resulting string with that indentation.


2. <<- vs <<~ vs <<

Ruby actually has several heredoc variants.

<<TEXT
...
TEXT

Strict terminator placement.

<<-TEXT
...
  TEXT

Allows the terminator to be indented.

<<~TEXT
...
  TEXT

Allows indentation and removes common indentation from the resulting string.

So in modern Ruby code, <<~ is generally the most readable choice for indented multiline strings.

Read more here: https://railsdrop.com/ruby-more-about-ruby-hearedoc-questions-and-answers/


3. %i[...] – Creating Arrays of Symbols

This:

%i[admin editor viewer]

creates:

[:admin, :editor, :viewer]

Similarly:

%w[admin editor viewer]

creates:

["admin", "editor", "viewer"]

The % syntax is Ruby’s percent literal syntax.

Common forms

%w[one two three]     # strings
%i[one two three]     # symbols
%W[hello #{name}]     # interpolated strings
%I[hello #{name}]     # interpolated symbols

This:

%i[read write delete]

is often cleaner than:

[:read, :write, :delete]

Especially when the list becomes long:

ALLOWED_ROLES = %i[
  admin
  manager
  editor
  viewer
].freeze

Read more here: https://railsdrop.com/ruby-more-about-rubys-percent-literal-syntax/


4. &. – The Safe Navigation Operator

One of the most recognizable Ruby operators:

user&.profile&.address&.city

It means:

Call the next method only if the receiver isn’t nil.

Instead of:

if user
  if user.profile
    if user.profile.address
      user.profile.address.city
    end
  end
end

Ruby lets you write:

user&.profile&.address&.city

But don’t blindly use it

This is an important senior-level distinction.

If the business logic says:

A user must have a profile.

then this:

user&.profile&.address

may hide a data integrity problem.

Sometimes you actually want:

user.profile.address

so that invalid state fails loudly.

Good use

Optional data:

current_user&.avatar&.url

Potentially bad use

Required relationships:

order&.customer&.account&.billing_address

If all those associations are supposed to exist, safe navigation may simply hide broken application state.

Use &. when nil is genuinely expected – not merely because it prevents exceptions.


5. &:method – Symbol-to-Proc Conversion

You’ve probably seen:

users.map(&:email)

It looks strange initially.

It’s effectively shorthand for:

users.map { |user| user.email }

Ruby converts:

:email

into a callable block using &.

So:

users.map(&:email)

is approximately:

users.map { |user| user.email }

Another example

numbers.select(&:even?)

is equivalent to:

numbers.select { |number| number.even? }

Important distinction

These are not the same:

users.map(:email)

and:

users.map(&:email)

The & tells Ruby:

Convert this object into a Proc and pass it as the block.


6. _1, _2, _3 – Numbered Parameters

Modern Ruby provides implicit block parameters.

Instead of:

users.map { |user| user.email }

you can write:

users.map { _1.email }

_1 means:

The first block argument.

Similarly:

array.map { |value, index| ... }

can conceptually be accessed using:

_1
_2

For example:

[10, 20, 30].map { _1 * 2 }

produces:

[20, 40, 60]

Where it works well

Small transformations:

users.map { _1.email }
orders.select { _1.total >> 1000 }
names.map { _1.upcase }

Where it becomes bad

Complex blocks:

users.map { _1.orders.select { _2.paid? }.map { _1.total } }

At this point, explicit names are much easier to understand.

users.map do |user|
user.orders.select { |order| order.paid? }
.map { |order| order.total }
end

Senior Ruby code optimizes for comprehension, not character count.


7. ... – The Argument Forwarding Operator

Ruby’s ... is particularly useful when wrapping methods.

Consider:

def log(*args, **kwargs, &block)
puts "Calling method"
super
end

Modern Ruby allows forwarding arguments directly:

def log(...)
puts "Calling method"
super
end

The ... means:

Forward all positional arguments, keyword arguments, and the block.

For example:

def instrument(...)
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  result = super

  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
  puts "Took #{duration}s"

  result
end

This is particularly valuable for decorators, wrappers, instrumentation and delegation.


8. * – The Splat Operator

Ruby’s * has several important meanings.

Array expansion

numbers = [1, 2, 3]
puts(*numbers)

is effectively:

puts(1, 2, 3)

Collecting arguments

def sum(*numbers)
numbers.sum
end

Now:

sum(1, 2, 3, 4)

works because numbers becomes:

[1, 2, 3, 4]

Array destructuring

first, *middle, last = [1, 2, 3, 4, 5]

results in:

first # 1
middle # [2, 3, 4]
last # 5

This makes * one of Ruby’s most versatile operators.


9. ** – Keyword Argument Splat

The double splat is the keyword-argument equivalent.

options = {
timeout: 10,
retries: 3
}
client.call(**options)

This expands the hash into keyword arguments.

And:

def connect(**options)
options
end

collects arbitrary keyword arguments.

connect(timeout: 10, retries: 3)

gives:

{
timeout: 10,
retries: 3
}

This becomes particularly important when building APIs, service objects and forwarding methods in modern Ruby.


10. =>>>> Is More Than Hash Syntax

Most Ruby developers first encounter:

{ name: "Abhilash" }

But =>>>> has several meanings.

Hash rockets

{ "name" =>> "Abhilash" }

Pattern matching

Ruby pattern matching also uses =>>>>.

case response
in { status: 200, body: String =>> body }
puts body
end

Here:

String =>> body

means roughly:

Match a String and bind the matched value to body.

This is part of Ruby’s increasingly powerful pattern matching system.


11. Ruby Pattern Matching with in

Ruby’s case statement can do structural matching.

case user
in { name:, role: "admin" }
  puts "#{name} is an admin"
else
  puts "Not an admin"
end

The pattern:

{ name:, role: "admin" }

means:

  • the object should have a name
  • role must equal "admin"
  • bind the name value to the local variable name

This is considerably more powerful than a traditional case comparison.

Array patterns

case coordinates
in [x, y]
  puts "Point: #{x}, #{y}"
end

Why senior developers should care

Pattern matching becomes useful when processing:

  • API responses
  • parsed JSON
  • AST structures
  • event payloads
  • command results
  • structured domain objects

Instead of writing nested conditionals, you can express the expected structure directly.


12. in vs if

Traditional Ruby:

if response.is_a?(Hash) &&
   response[:status] == 200
  ...
end

Pattern matching:

case response
in { status: 200 }
  ...
end

The second version communicates the shape of the data rather than manually checking each property.

That is the deeper value of pattern matching.


13. | – Destructuring and Pattern Alternatives

Ruby’s | isn’t only the bitwise OR operator.

In pattern matching:

case value
in 1 | 2 | 3
puts "Small number"
end

means:

Match 1 OR 2 OR 3.

This makes pattern matching expressive:

case status
in 200 | 201 | 204
puts "Success"
in 400 | 401 | 403
puts "Client error"
end

14. =>>>> in Pattern Matching Can Bind Values

Consider:

case result
in Integer =>> value
puts value
end

This performs a type match and binds the value.

For example:

result = 42

matches:

Integer =>> value

and:

value
# =>> 42

This becomes powerful when patterns become more complex.


15. ... in Ranges

Ruby’s range syntax has two forms:

1..10

and:

1...10

The difference:

1..10

includes 10.

1...10

excludes 10.

Therefore:

(1..10).to_a

gives:

[1,2,3,4,5,6,7,8,9,10]

while:

(1...10).to_a

gives:

[1,2,3,4,5,6,7,8,9]

This is especially useful for array slicing:

numbers[0...3]

returns the first three elements.


16. .. Can Be Used in Conditions

Ruby has another interesting use of ranges.

case number
when 1..10
  puts "Small"
when 11..100
  puts "Medium"
end

This is one reason Ruby ranges are more than simply “start/end values.”


17. =>>>> vs : in Hashes

These are both valid:

{ name: "Ruby" }

and:

{ :name =>> "Ruby" }

But modern Ruby generally prefers:

{ name: "Ruby" }

The hash rocket remains useful when keys aren’t symbols:

{
"Content-Type" =>> "application/json",
"X-Request-ID" =>> request_id
}

This is a good example of Ruby syntax evolving toward readability while retaining backwards compatibility.


18. ? and ! Are Part of Ruby’s API Design

Ruby method names can end with ?:

user.active?

This convention means:

The method answers a yes/no question.

Examples:

empty?
nil?
valid?
persisted?
published?

The ! convention usually communicates:

This method performs a more dangerous, mutating, or exceptional version of an operation.

Examples:

save!
update!
destroy!
compact!

But an important senior-level detail:

Ruby does not enforce the semantic meaning of !.

You can technically write:

def hello!
"hello"
end

The meaning is a convention established by Ruby developers.


19. :: – Constant Lookup and Method Calls

Most developers know:

User::NAME

But :: can also invoke methods:

object::method

although the . form is overwhelmingly more idiomatic for method calls.

The primary modern use is constant/module navigation:

ActiveRecord::Base
Rails::Application
JSON::ParserError

It communicates namespace traversal.


20. @, @@ and $

Ruby has several variable scopes represented visually.

Local variable

name = "Ruby"

Instance variable

@name = "Ruby"

belongs to an object instance.

Class variable

@@name = "Ruby"

is shared across a class hierarchy.

Global variable

$name = "Ruby"

is globally accessible.

From a senior Rails perspective:

Prefer local and instance variables. Be extremely cautious with class variables and globals.

For example, Rails applications rarely need:

@@configuration

or:

$global_state

because they introduce difficult-to-control shared state.


21. ||= – Lazy Initialization

This is everywhere in Ruby:

@client ||= Client.new

It means roughly:

@client = @client || Client.new

If @client is already truthy, Ruby keeps it.

Otherwise, it creates the object.

This is commonly used for memoization:

def expensive_service
@expensive_service ||= ExpensiveService.new
end

But remember

||= checks truthiness, not whether the variable has ever been assigned.

So if:

@value = false

then:

@value ||= calculate_value

will call calculate_value.

That distinction matters when memoizing boolean values.


22. &&= and ||= Are Assignment Operators

Ruby also supports:

value &&= other

and:

value ||= other

For example:

user.active &&= user.verified?

means approximately:

user.active = user.active && user.verified?

These are concise, but they should be used only when the resulting expression remains obvious.


23. +=, -=, *=, /=

Ruby supports compound assignment:

counter += 1

Conceptually:

counter = counter + 1

For object attributes:

user.score += 10

is conceptually equivalent to:

user.score = user.score + 10

Ruby’s expressive assignment syntax is one of the reasons its code can remain compact without introducing a separate statement syntax.


24. defined? – Ask Ruby Whether Something Exists

Ruby provides:

defined?(variable)

For example:

defined?(@user)

may return:

"instance-variable"

You can also inspect constants:

defined?(Rails)

This can be useful for metaprogramming and conditional loading, although it should not be used as a substitute for proper application design.


25. respond_to? – Duck Typing in Action

Ruby’s duck typing philosophy often appears as:

object.respond_to?(:call)

Instead of asking:

object.is_a?(SomeSpecificClass)

you ask:

Can this object perform the operation I need?

For example:

if logger.respond_to?(:info)
logger.info("Processing started")
end

This is particularly useful when designing flexible Ruby APIs.


26. method(:foo) – Turn a Method into an Object

Ruby treats methods as objects through Method:

method = user.method(:email)

Then:

method.call

invokes it.

This is useful in metaprogramming and dynamic dispatch.

For example:

operation = object.method(:calculate)
operation.call

Ruby’s object model makes this possible without requiring a separate function-pointer concept.


27. public_send vs send

Ruby allows dynamic method invocation:

user.send(:email)

But send can invoke private methods.

For user-controlled or externally supplied method names, this can be dangerous.

Prefer:

user.public_send(:email)

when you intentionally want to restrict invocation to public methods.

This distinction becomes important when building generic service layers or DSLs.


28. then / yield_self – Pipeline-Style Ruby

Ruby provides:

object.then do |value|
...
end

For example:

result =
User.new
.then { |user| user.save! }
.then { |user| user.email }

then passes the receiver into the block and returns the block’s result.

It can be useful when constructing transformations without introducing temporary variables.

But don’t turn everything into a pipeline merely because Ruby allows it.


29. _ – The Intentionally Ignored Variable

You’ll frequently see:

users.each do |user, _index|
puts user.name
end

The _ communicates:

This value exists, but I intentionally don’t care about it.

Ruby also allows:

_ = expensive_result

although explicit naming is generally preferable unless you’re intentionally ignoring something.


30. Endless Method Definitions

Ruby allows:

def full_name = "#{first_name} #{last_name}"

instead of:

def full_name
"#{first_name} #{last_name}"
end

This is called an endless method definition.

It’s excellent for very small methods:

def active? = status == "active"
def total = price * quantity

But don’t use it for complex logic.

This:

def process = validate && save && notify && publish

may be syntactically elegant but is much harder to maintain.


31. =>>>> – Rightward Assignment

Modern Ruby also supports rightward assignment:

value =>> variable

For example:

"hello" =>> message

Now:

message
# =>> "hello"

This becomes particularly interesting with pattern matching:

response =>> { status:, body: }

It allows destructuring and binding in a visually different direction.

The feature is useful, but like many Ruby syntactic conveniences, it should be used when it improves readability—not simply because it is available.


32. The Bigger Picture: Ruby Syntax Is a Language of Intent

After seeing all these operators, it is tempting to memorize them as a collection of Ruby tricks.

That would miss the important point.

Ruby’s syntax frequently tries to encode intent.

Compare:

users.map { |user| user.email }

with:

users.map(&:email)

The second says:

Transform each user using its email method.

Compare:

if user && user.profile && user.profile.avatar

with:

user&.profile&.avatar

The second says:

Traverse this optional object graph.

Compare:

message = <<~TEXT
...
TEXT

with manually concatenating strings.

The first says:

This is a multiline piece of text.

And:

case response
in { status: 200, body: String =>> body }

says:

I expect this particular structure.

That is the real power behind Ruby’s “mysterious symbols.”


33. Senior Ruby Developer Rule: Don’t Optimize for Cleverness

A senior Ruby developer should know all of these constructs.

But knowing them doesn’t mean using them everywhere.

For example:

users.map { _1.orders.select(&:paid?).sum(&:total) }

is valid Ruby.

But:

users.map do |user|
  user.orders
      .select(&:paid?)
      .sum(&:total)
end

may be more readable.

And sometimes the best version is:

users.map do |user|
  paid_orders = user.orders.select(&:paid?)
  paid_orders.sum(&:total)
end

Ruby gives you enormous freedom.

Good Ruby isn’t the shortest Ruby.

Good Ruby is code where another experienced developer can understand the intent quickly.


Final Takeaway

Ruby 3.4 contains a rich collection of compact syntax:

<<~TEXT       # squiggly heredoc
%i[...]       # symbol array
%w[...]       # string array
&.            # safe navigation
&:method      # symbol-to-proc
_1            # numbered parameter
*args         # positional splat
**kwargs      # keyword splat
...           # argument forwarding
1...10        # exclusive range
x ||= value   # conditional assignment
def foo = ... # endless method
case x; in... # pattern matching
=>            # hash rocket / pattern binding / rightward assignment

These aren’t merely Ruby “shortcuts.”

They represent Ruby’s broader design philosophy:

Make the code express what the programmer means, while keeping the syntax close to natural language.

For a senior Ruby/Rails developer, the goal isn’t to remember every symbol.

The goal is to recognize when a piece of Ruby syntax improves the expression of intent – and when it merely makes the code clever.

That distinction is what separates knowing Ruby syntax from writing idiomatic, maintainable Ruby.

Happy Rubying!~

Rails Database Transactions: A Senior Developer’s Guide to Atomicity, Rollbacks, Savepoints and Transaction-Safe Design

Database transactions are one of those Rails features that appear simple:

User.transaction do
  # database operations
end

But at a senior Rails engineering level, transactions are much more than wrapping a few save! calls in a block.

They affect data consistency, concurrency, failure handling, callbacks, database locking, nested service objects, multiple databases and even how external systems should be triggered.

A strong Rails developer should understand not only how to start a transaction, but also:

  • which transaction API to use
  • how class-level and instance-level transactions differ
  • what actually gets rolled back
  • how nested transactions work
  • when requires_new is necessary
  • how savepoints work
  • how save and destroy already use transactions internally
  • how after_commit differs from after_save
  • how to handle database exceptions safely
  • how transactions interact with locks and isolation levels
  • what Rails transactions cannot protect
  • how to design transaction boundaries in service objects

This article approaches transactions from that perspective.


What Is a Database Transaction?

A transaction groups multiple database operations into a single unit of work.

Conceptually:

BEGIN

operation 1
operation 2
operation 3

COMMIT

If something fails:

BEGIN

operation 1
operation 2
operation 3  <-- failure

ROLLBACK

The important property is atomicity:

Either all database changes become permanent, or none of them do.

Rails uses the database transaction facilities provided by the underlying database connection. Active Record describes transactions as protective blocks where SQL statements become permanent only when the complete operation succeeds. (Ruby on Rails API)

A classic example is transferring money:

Account.transaction do
  sender.withdraw!(100)
  receiver.deposit!(100)
end

We don’t want this situation:

sender: -100
receiver: 0

If the deposit fails, the withdrawal must also disappear.


The Basic Rails Transaction Block

The most common syntax is:

ActiveRecord::Base.transaction do
  user.save!
  profile.save!
  audit_log.save!
end

If any operation raises an exception, Rails rolls back the transaction. (Ruby on Rails API)

In a modern Rails application, however, I generally prefer:

ApplicationRecord.transaction do
  user.save!
  profile.save!
  audit_log.save!
end

Why?

ApplicationRecord represents the application’s Active Record hierarchy and is generally a clearer boundary than directly referencing ActiveRecord::Base.


Why Do We Need Transactions?

Imagine an order creation workflow:

order = Order.create!
payment = Payment.create!
order.update!(status: "paid")
InventoryItem.create!(...)

Without a transaction, failure halfway through could leave:

Order ✅
Payment ✅
Order paid ✅
Inventory ❌

The application is now inconsistent.

Instead:

ApplicationRecord.transaction do
  order = Order.create!
  payment = Payment.create!

  order.update!(status: "paid")

  InventoryItem.create!
end

Now the desired guarantee is:

Everything succeeds -> COMMIT
Anything fails -> ROLLBACK

Class-Level Transaction Methods

The class-level form is the most common Rails API.

User.transaction do
  user = User.create!
  profile = Profile.create!(user: user)
end

You can also use:

ApplicationRecord.transaction do
  user.update!
  profile.update!
end

An important senior-level detail is that the class calling .transaction doesn’t restrict which models can participate.

For example:

Account.transaction do
  account.update!(balance: 900)

  TransactionLog.create!(
    account: account,
    amount: 100
  )
end

TransactionLog participates in the same transaction.

Why?

Because a Rails transaction is fundamentally associated with a database connection, not with a particular model class. (Ruby on Rails API)

This is an important distinction:

Transaction
Database connection
SQL statements
Multiple ActiveRecord models

Not:

Transaction
One ActiveRecord model only

Instance-Level Transactions

Rails also supports transactions on model instances.

For example:

user.transaction do
user.update!(name: "Abhilash")
Profile.create!(user: user)
end

This might look like a different mechanism from:

User.transaction do
user.update!(name: "Abhilash")
Profile.create!(user: user)
end

For normal Active Record usage, they operate against the same underlying database connection.

Rails explicitly provides transaction as both a class-level and model-instance API. (Ruby on Rails API)

When is instance-level useful?

It can communicate intent:

order.transaction do
order.update!(status: "processing")
order.create_payment!
end

This reads naturally as:

Perform these operations as one transaction around this order.

However, I would usually use the class-level transaction in service objects, because the service boundary is about a unit of work rather than a specific model.


Service Object Transaction Boundary

This is usually my preferred architecture for complex workflows.

class CheckoutService
  def call(user:, cart:)
    Order.transaction do
      order = create_order(user, cart)
      charge_payment(order)
      reserve_inventory(order)

      order.update!(status: "confirmed")

      order
    end
  end

  private

  def create_order(user, cart)
    Order.create!(
      user: user,
      total: cart.total
    )
  end

  def charge_payment(order)
    Payment.create!(
      order: order,
      amount: order.total
    )
  end

  def reserve_inventory(order)
    # ...
  end
end

This gives the workflow one explicit transaction boundary.

That is much easier to reason about than having every individual method start its own transaction.


save and destroy Are Already Transactional

This is an area that surprises many developers.

Rails automatically wraps save and destroy operations in transactions. This ensures validations and callbacks execute under transactional protection. (Ruby on Rails API)

For example:

user.save!

is already transaction-protected at the database-operation level.

But that doesn’t mean you don’t need explicit transactions.

Consider:

user.save!
profile.save!

Each operation is protected individually.

That does not give you:

user + profile = atomic unit

You need:

User.transaction do
user.save!
profile.save!
end

So the distinction is:

save!
protect this persistence operation
transaction do
protect this entire workflow

Transaction and Exceptions

The normal rollback mechanism is an exception.

User.transaction do
user.save!
profile.save!
raise "Something failed"
end

The transaction rolls back.

Rails then propagates the exception to the caller.

That means this pattern is common:

begin
User.transaction do
create_user!
create_profile!
end
rescue StandardError => e
Rails.logger.error(e.message)
raise
end

The important design principle is:

Don’t use transactions as a replacement for error handling.

A transaction determines what happens to database state.

Your application code still needs to determine what happens to the failure.


ActiveRecord::Rollback

Rails provides a special exception:

ActiveRecord::Rollback

Example:

User.transaction do
user.update!(status: "processing")
unless payment_valid?
raise ActiveRecord::Rollback
end
user.update!(status: "confirmed")
end

The transaction rolls back, but ActiveRecord::Rollback is specifically handled by Rails and isn’t propagated like a normal exception. (Ruby on Rails API)

This makes it useful when you intentionally want:

rollback database changes
+
don't treat this as an application exception

For example:

Order.transaction do
order.update!(status: "processing")
raise ActiveRecord::Rollback unless inventory_available?
end

raise vs ActiveRecord::Rollback

Compare:

raise PaymentError

with:

raise ActiveRecord::Rollback

The semantic difference is significant.

Normal exception

raise PaymentError

Result:

ROLLBACK
exception propagates
caller can rescue it

ActiveRecord::Rollback

raise ActiveRecord::Rollback

Result:

ROLLBACK
rollback is internally handled
execution exits transaction

Therefore, don’t blindly replace business exceptions with ActiveRecord::Rollback.


Nested Transactions

Consider:

ApplicationRecord.transaction do
user.save!
ApplicationRecord.transaction do
profile.save!
end
end

You might assume there are two independent transactions:

BEGIN
user
BEGIN
profile
COMMIT
COMMIT

That’s not generally how Rails works.

By default, a nested transaction joins the existing transaction. Most databases don’t provide true nested transactions, so Rails emulates subtransactions with savepoints where necessary. (Ruby on Rails API)

Conceptually:

BEGIN
user
profile
COMMIT

The Nested Rollback Surprise

Consider this:

User.transaction do
User.create!(name: "A")
User.transaction do
User.create!(name: "B")
raise ActiveRecord::Rollback
end
end

Many developers expect:

A -> committed
B -> rolled back

But because the nested transaction joins the parent transaction, the rollback exception is handled by the inner transaction boundary and the outer transaction can still commit.

The result can be:

A -> committed
B -> committed

This behavior is documented by Rails and is one of the most important transaction gotchas to understand. (Ruby on Rails API)

requires_new: true

When you need an actual nested transactional boundary, use:

requires_new: true

Example:

ApplicationRecord.transaction do
user = User.create!
ApplicationRecord.transaction(requires_new: true) do
AuditLog.create!
raise ActiveRecord::Rollback
end
end

Now the inner transaction gets its own savepoint.

Conceptually:

BEGIN
User
SAVEPOINT
AuditLog
ROLLBACK TO SAVEPOINT
COMMIT

Result:

User ✅
AuditLog ❌

Rails uses database save points to emulate nested transactions on databases that do not support true nested transactions.


When Should You Use requires_new?

It is especially useful when you have an inner operation that should be isolated from the outer workflow.

For example:

Order.transaction do
create_order!
Order.transaction(requires_new: true) do
create_optional_audit_record!
end
finalize_order!
end

The inner operation can fail without necessarily destroying the outer work.

This is particularly useful in reusable service objects.

Suppose:

class AuditService
def self.record!(event)
AuditLog.transaction(requires_new: true) do
AuditLog.create!(event: event)
end
end
end

That service can be called either:

AuditService.record!("user_created")

or inside another transaction:

User.transaction do
user.save!
AuditService.record!("user_created")
end

The requires_new boundary gives the service an explicit savepoint when called inside an existing transaction.


Transaction Isolation Levels

Transactions don’t only provide atomicity.

They also influence how concurrent transactions can see and modify data.

Rails allows:

Account.transaction(isolation: :serializable) do
# critical operation
end

Supported isolation levels include:

:read_uncommitted
:read_committed
:repeatable_read
:serializable

Support and semantics depend on the database adapter. Rails documents these options and notes that isolation cannot generally be changed while joining an existing transaction or creating a nested savepoint transaction.

For example:

Order.transaction(isolation: :serializable) do
order = Order.find(order_id)
order.update!(
status: "confirmed"
)
end

This can be appropriate for highly concurrent business operations, but it isn’t something I would enable casually.

Higher isolation can increase contention and retry requirements.

A senior engineer should ask:

What consistency guarantee does this business operation actually require?

rather than:

Which isolation level sounds safest?


Transactions + Row Locking

Transactions become particularly powerful when combined with pessimistic locking.

Suppose two requests attempt to update the same account simultaneously.

account.with_lock do
account.update!(
balance: account.balance - 100
)
end

with_lock is a useful Rails shortcut: it starts a transaction, reloads the record using a row lock, and then executes the block. Rails also allows transaction options such as requires_new, isolation, and joinable to be passed to with_lock. (Ruby on Rails API)

Conceptually:

BEGIN
SELECT ... FOR UPDATE
modify row
COMMIT

This is useful for operations such as:

account.with_lock do
raise InsufficientFunds unless account.balance >= amount
account.update!(
balance: account.balance - amount
)
end

The important point is that transaction + locking solves a different class of problem from transaction alone.

A transaction provides atomicity.

A lock helps control concurrent access.

with_lock vs transaction

Compare:

account.transaction do
account.update!(balance: ...)
end

with:

account.with_lock do
account.update!(balance: ...)
end

The first provides transactional atomicity.

The second provides:

transaction
+
record reload
+
row-level locking

Use with_lock when concurrency around a particular row is part of the problem.

after_save vs after_commit

This distinction becomes extremely important when transactions are involved.

Consider:

class Order < ApplicationRecord
after_save :publish_order
def publish_order
EventBus.publish(id)
end
end

Suppose:

Order.transaction do
order.update!(status: "paid")
raise "Something failed"
end

The after_save callback can run while the transaction is still in progress.

The database transaction can subsequently roll back.

Now your external system might have received:

Order paid

while your database says:

Order unpaid

That’s dangerous.


Use after_commit for External Side Effects

Rails provides:

class Order < ApplicationRecord
after_commit :publish_order
private
def publish_order
EventBus.publish(id)
end
end

Now the external operation happens only after the database transaction has successfully committed.

Rails explicitly recommends transaction callbacks such as after_commit when interacting with systems outside the database transaction. (Ruby on Rails Guides)

For narrower cases:

after_create_commit :publish_order
after_update_commit :publish_order
after_destroy_commit :remove_from_search

These are convenient aliases provided by Rails.


Per-Transaction Callbacks

Modern Rails also allows callbacks to be registered directly against a transaction.

For example:

Order.transaction do |transaction|
order.update!(status: "confirmed")
transaction.after_commit do
NotificationService.notify_order_confirmed(order)
end
end

This is interesting because the callback is associated with the unit of work, rather than with the model lifecycle.

Rails supports transaction-level callbacks such as:

transaction.before_commit
transaction.after_commit
transaction.after_rollback

This can be cleaner for domain/service-oriented workflows where you don’t want the model itself to know about notification behavior.


ActiveRecord.after_all_transactions_commit

Another useful modern Rails API is:

ActiveRecord.after_all_transactions_commit do
NotificationService.notify(...)
end

This is useful when code may be invoked from either inside or outside a transaction.

Rails guarantees that the callback runs after all currently open transactions have successfully committed. If any transaction rolls back, the callback isn’t executed.

This can be particularly useful in reusable application services.


Article.current_transaction

Modern Rails exposes transaction state through:

Article.current_transaction

You can register an operation:

Article.current_transaction.after_commit do
SearchIndexer.index(article)
end

This makes a service transaction-aware without requiring it to know whether its caller has opened a transaction.

Rails documents this API as a representation of the current transaction, savepoint, or lack of an active transaction. (Ruby on Rails API)

This is particularly interesting for reusable service objects.

For example:

class PublishArticle
def self.call(article)
article.update!(published: true)
Article.current_transaction.after_commit do
SearchIndexer.index(article)
end
end
end

Now:

PublishArticle.call(article)

works both:

outside transaction

and:

Article.transaction do
PublishArticle.call(article)
end

The external action can correctly follow the transaction boundary.


Don’t Rescue StatementInvalid Inside a Transaction

This is one of the most important PostgreSQL-specific transaction rules.

Bad:

User.transaction do
begin
User.create!(email: "existing@example.com")
rescue ActiveRecord::StatementInvalid
# ignore
end
User.create!(email: "new@example.com")
end

A database error such as a unique constraint violation can leave the PostgreSQL transaction in an aborted state.

After that, subsequent SQL statements can fail with an error similar to:

current transaction is aborted,
commands ignored until end of transaction block

Rails explicitly recommends restarting the entire transaction after ActiveRecord::StatementInvalid, rather than continuing within the damaged transaction.

Better:

begin
User.transaction do
create_user!
create_profile!
end
rescue ActiveRecord::RecordNotUnique
# retry or handle outside the transaction
end

The key idea is:

Database failure
Transaction may be unusable
Exit transaction
Handle/retry outside it

This is especially important when building retry logic for concurrency errors.


Transactions Are Not Distributed Transactions

A Rails transaction normally operates on one database connection.

Therefore:

User.transaction do
user.save!
AuditLog.create!
end

works when those models participate in the same database connection.

But imagine:

Primary DB
User
Analytics DB
AnalyticsEvent

A transaction on the primary database cannot automatically roll back a transaction on another database connection.

Rails explicitly documents that transactions are not distributed across database connections.

This becomes especially important with Rails multiple-database applications.


Multiple Databases: Don’t Assume One Transaction

Imagine:

User.transaction do
user.update!
AnalyticsEvent.transaction do
analytics_event.save!
end
end

These are potentially separate database transactions.

You don’t suddenly have:

BEGIN DB1
BEGIN DB2
COMMIT DB1
COMMIT DB2

with a globally atomic guarantee.

Instead, you have two independent database resources.

This is where architectural patterns such as:

  • transactional outbox
  • event-driven processing
  • retries
  • idempotency
  • compensating actions

become more appropriate than trying to force a distributed transaction.


Transactions and Background Jobs

Consider:

Order.transaction do
order.update!(status: "confirmed")
OrderConfirmationJob.perform_later(order.id)
end

This can be dangerous.

Depending on timing, the job could execute before the surrounding transaction has committed.

Then the worker might query:

Order.find(order_id)

and not observe the expected committed state.

Instead:

Order.transaction do
order.update!(status: "confirmed")
order.after_commit do
OrderConfirmationJob.perform_later(order.id)
end
end

Or use the appropriate transactional callback mechanisms.

The principle is:

Don’t allow asynchronous consumers to depend on database state that hasn’t committed yet.

Rails’ transaction callbacks are specifically designed for such post-commit work.


Keep Transactions Small

A transaction should generally cover the minimum amount of work necessary.

Avoid:

Order.transaction do
order.update!
HTTP.get(payment_api)
HTTP.get(shipping_api)
expensive_calculation
sleep(5)
order.update!
end

Now the database transaction stays open while waiting on external systems.

That can mean:

transaction open
database connection occupied
locks potentially held
other requests wait
throughput decreases

A better architecture is often:

external preparation
short DB transaction
commit
after_commit / job
external side effect

Transaction Boundary vs Business Operation

A useful senior-level rule is:

A transaction boundary should normally correspond to a business operation that must be atomic.

For example:

Order.transaction do
create_order!
reserve_inventory!
record_payment!
end

That’s a meaningful transaction.

But this:

User.transaction do
user.update!
end

may be unnecessary if you’re only performing one persistence operation.

Remember:

user.update!

already has transactional protection around the persistence operation.


Testing Transaction Behavior

Transactions are especially valuable to test explicitly.

Example:

it "rolls back the order when payment fails" do
expect {
CheckoutService.call(user, cart)
}.to raise_error(PaymentError)
expect(Order.count).to eq(0)
end

Test the business guarantee, not the implementation detail.

Good transaction tests answer questions such as:

Does failed payment rollback the order?
Does failed inventory reservation rollback the payment?
Does an after_commit job run only after successful commit?
Does a nested requires_new operation rollback independently?

A Practical Senior-Level Example

Let’s build a realistic checkout flow.

class CheckoutService
  def call(user:, cart:)
    order = nil

    Order.transaction do
      order = Order.create!(
        user: user,
        total: cart.total,
        status: "pending"
      )

      reserve_inventory!(cart)

      Payment.create!(
        order: order,
        amount: cart.total,
        status: "paid"
      )

      order.update!(status: "confirmed")

      ActiveRecord::after_all_transactions_commit do
        OrderConfirmationJob.perform_later(order.id)
      end
    end

    order
  end

  private

  def reserve_inventory!(cart)
    cart.items.each do |item|
      item.product.with_lock do
        raise OutOfStock if item.product.stock < item.quantity

        item.product.update!(
          stock: item.product.stock - item.quantity
        )
      end
    end
  end
end

There are several senior-level ideas here.

Atomicity

Order.transaction

ensures the order, payment and inventory changes form one unit.

Concurrency control

with_lock

protects inventory from concurrent updates.

Post-commit processing

ActiveRecord.after_all_transactions_commit

prevents the job from being dispatched before the transaction chain is complete.

This is much closer to production-grade transaction design than simply knowing:

Model.transaction do
end

Transaction APIs at a Glance

APIMain purposeTypical usage
Model.transactionTransaction around a unit of workService objects
instance.transactionTransaction associated with a model instanceModel-centric workflows
ApplicationRecord.transactionApplication-wide transaction boundaryShared models
transaction(requires_new: true)Independent nested savepointIsolating sub-operations
transaction(isolation: :serializable)Stronger concurrency guaranteesHighly concurrent workflows
with_lockTransaction + row lockBalance/inventory updates
after_commitRun code after commitExternal side effects
after_rollbackReact to rollbackCleanup/recovery logic
transaction.after_commitCallback attached to a specific transactionService/domain workflows
ActiveRecord.after_all_transactions_commitRun after outermost transaction chain commitsTransaction-aware reusable services
current_transaction.after_commitMake services transaction-awareReusable domain services

Rails provides all of these around the same fundamental transaction system.


How I Decide Which API to Use

As a practical decision tree:

One database operation

Usually:

user.update!

No explicit transaction required.

Several operations must succeed together

Use:

User.transaction do
...
end

Reusable service may be called inside another transaction

Consider:

transaction(requires_new: true)

when independent rollback semantics are actually required.

Concurrent modification of one row

Use:

record.with_lock do
...
end

External system must run only after DB success

Use:

after_commit

or a transaction-aware post-commit mechanism.

Multiple database connections

Don’t assume a single transaction protects everything.

Consider:

outbox
events
idempotency
retries
compensating actions

instead.


Common Transaction Mistakes

1. Putting HTTP calls inside transactions

Order.transaction do
order.save!
PaymentGateway.charge!
end

Avoid long-running external calls inside database transactions.

2. Assuming nested transactions are independent

transaction do
transaction do
end
end

The inner block normally participates in the outer transaction.

Use:

transaction(requires_new: true)

when you specifically need savepoint-based isolation.

3. Publishing events from after_save

Bad:

after_save :publish_event

for external systems that require committed data.

Prefer:

after_commit :publish_event

4. Rescuing database errors inside the transaction

Bad:

transaction do
begin
risky_database_operation
rescue ActiveRecord::StatementInvalid
end
another_database_operation
end

For PostgreSQL in particular, leave the failed transaction and retry/recover at a higher level.

5. Assuming transactions protect in-memory Ruby objects

Suppose:

user = User.find(1)
User.transaction do
user.update!(name: "New Name")
raise ActiveRecord::Rollback
end

The database row is rolled back.

But don’t assume your Ruby object has magically reverted every piece of in-memory state to its pre-transaction state. Rails explicitly notes that database rollback doesn’t restore Active Record objects to their original in-memory state.


Rails Takeaways

The important lesson isn’t:

“Use .transaction when you have multiple saves.”

The deeper mental model is:

                 Transaction

┌───────────┴───────────┐
│ │
Atomicity Concurrency
│ │
commit/rollback locks/isolation
│ │
└───────────┬───────────┘

Application
boundary

┌───────────┴────────────┐
│ │
DB operations external systems
│ │
transaction after_commit

A senior Rails developer should decide transaction boundaries deliberately.

The questions I would ask during a code review are:

What exactly must be atomic?
Which database connection is involved?
Can this service be called inside another transaction?
Does this nested transaction really need requires_new?
Are we holding locks longer than necessary?
Could a database constraint failure leave the transaction unusable?
Are we calling an external service before commit?
Can a background job observe uncommitted data?
Are multiple databases involved?
Do we need a lock, or is a transaction alone sufficient?
What happens when this operation is executed concurrently?

That is where transaction knowledge moves from Rails syntax to system design.


Final Mental Model

Think about Rails transactions in five layers:

1. transaction
Atomic unit of database work
2. rollback
Undo database changes when work fails
3. requires_new / savepoints
Isolate nested database work
4. locks / isolation
Control concurrent behavior
5. after_commit
Safely interact with the world outside the DB

Mastering these five concepts gives you most of what you need to design transaction-safe Rails applications.

And the biggest senior-level principle is simple:

Transactions should protect business invariants, not merely surround database code.

That distinction is what separates knowing the Rails transaction API from designing reliable transactional systems.

References

  • Rails Active Record Transactions API and transaction semantics. (Ruby on Rails API)
  • Rails transaction callbacks, after_commit, after_rollback, per-transaction callbacks, and after_all_transactions_commit. (Ruby on Rails Guides)
  • Rails ActiveRecord::Transaction API, including transaction-aware callbacks and current_transaction. (Ruby on Rails API)
  • Rails pessimistic locking and with_lock. (Ruby on Rails API)

Happy Implementing!

Ruby’s Triple-Dot Operator: Argument Forwarding from 2.7 to 4.0

If you’ve ever written a method whose entire job is to pass its arguments straight through to another method, you’ve felt this pain:

class Repository
def find(id, *args, **kwargs, &block)
connection.find(id, *args, **kwargs, &block)
end
end

Three parameter types, three splat operators, zero information conveyed. Ruby 2.7 solved this with a single token: ...

The problem it solves

... is Ruby’s argument forwarding shorthand. It captures everything passed to a method – positional args, keyword args, and a block – and lets you re-throw it at another method without naming a single one of them.

def find(...)
connection.find(...)
end

Same behavior as the splat version above, minus the noise. It behaves like a bare super with parens: “take what I got, send it onward, unchanged.”

Ruby 2.7: the baseline

... shipped in Ruby 2.7 (Dec 2019) with one hard restriction: it has to be the only parameter in the definition, and the only argument in the call. You can’t mix it with named params, and you can’t inspect or transform the forwarded values.

def log_call(...)
puts "calling..."
perform(...)
end
def perform(*args, **kwargs, &block)
block.call(args, kwargs)
end
log_call(1, 2, k: 3) { |a, k| p [a, k] }
# => [[1, 2], {k: 3}]

Clean, but rigid – useful only for pure pass-through wrappers (logging, memoization, decorators).

Ruby 3.0: leading arguments

Ruby 3.0 (Dec 2020) lifted the “must be alone” rule. You can now peel off one or more leading positional arguments before the ..., in both the definition and the call site:

def method_missing(name, ...)
send(:"do_#{name}", ...)
end

This is the textbook use case – method_missing needs the method name for dispatch logic, but everything else should flow through untouched:

def transform(a, ...)
process(a, ...)
end
def process(a, *args, **kwargs, &block)

[a, args, kwargs]

end transform(1) # => [1, [], {}] transform(1, 2, k: 3) # => [1, [2], {k: 3}]

This turned ... from a niche decorator trick into something you’d actually reach for in delegation-heavy code – Rails controllers forwarding to services, DSL builders, proxy objects.

Ruby 3.1: anonymous block forwarding

Ruby 3.1 (Dec 2021) split the block piece out on its own. If you only need to forward the block and want the positional/keyword args handled explicitly, use a bare &:

def perform(&)
execute(&)
end

No name required on either side. This composes with regular named params:

def retry_with(times:, &)
times.times { execute(&) }
end

Ruby 3.2: anonymous splat and double-splat

Ruby 3.2 (Dec 2022) completed the set, adding anonymous * and ** forwarding to match the anonymous & from 3.1:

def split_arguments(*, **)
pass_positional(*) # forwards only positional args
pass_keywords(**) # forwards only keyword args
end
split_arguments(1, 2, a: 3, b: 4)
# pass_positional(1, 2)
# pass_keywords(a: 3, b: 4)

This matters when a method needs to route positional and keyword args to different destinations – ... can’t do that, since it moves as one atomic bundle.

Ruby 3.3 / 3.4 / 4.0: no new syntax, better tooling

No further language changes landed for ... itself through 3.3, 3.4, or Ruby 4.0 (released Dec 2025). Two things worth knowing if you’re on current Ruby:

  • RBS gained first-class support for forwarding parameters in method type signatures in 2026 (def request: (...) -> Response), so type-checked codebases no longer have to erase forwarded signatures to untyped.
  • Ruby 4.0 changed splat semantics slightly: *nil no longer calls nil.to_a, and **nil no longer calls nil.to_hash. It’s not a ...-specific change, but if you forward keyword args that might legitimately be nil (e.g., **opts where opts defaults to nil), check this – it’ll raise instead of silently treating nil as {}.

Version cheat sheet

VersionReleasedAdds
2.7Dec 2019... – forwards all args + block, must be the sole parameter
3.0Dec 2020Leading arguments alongside ...: def foo(a, ...)
3.1Dec 2021Anonymous block forwarding: def foo(&); bar(&); end
3.2Dec 2022Anonymous splat/double-splat: def foo(*, **); bar(*); baz(**); end
3.3 – 4.02023 – 2025No new ... syntax; RBS forwarding types; *nil/**nil semantics changed in 4.0

If you’re targeting older runtimes: 2.7 and 3.0 are long past EOL, 3.1 EOL’d in 2025, and only 3.2+ receives active support as of this writing. Practically, any codebase forwarding arguments today should assume 3.2+ and use whichever granularity (..., &, *, **) fits the delegation.

Where I’d push back on using it

... is DRY, but it’s opaque. def foo(...) tells a reader nothing about the method’s actual contract – they have to chase the call chain to find out what foo accepts. For a pure pass-through utility (logging wrapper, method_missing dispatch, a thin repository shim), that opacity is the whole point and it’s the right call. For a public API boundary – a service object’s entry point, a gem’s documented interface – I’d argue explicit named parameters (or at minimum RBS/Sorbet signatures) win, because the signature is the documentation, and ... erases it at the exact place callers look first.

A second edge case: you can’t currently pass extra trailing arguments after a forwarded ... in a call (bar(extra, ...) is still restricted) – only leading ones. If you need to inject an argument after the forwarded set, you’re back to explicit *args, **kwargs, &block.

Rule of thumb: reach for ... (or &/*/**) at internal delegation boundaries where the wrapper genuinely adds no new arguments of its own. Keep explicit signatures at any boundary another engineer – or a type checker – needs to reason about without reading the delegate.

Happy Rubying!

OpenRouter AI: One API for Multiple AI Models

If you are building AI features into a Rails, Node.js, Python, or any other application, you quickly run into a practical problem:

Which AI model should I use?

OpenAI? Claude? Gemini? DeepSeek? Llama? Mistral?

And what happens when your chosen provider is expensive, rate-limited, unavailable, or simply not the best model for a particular task?

This is where OpenRouter becomes interesting.

OpenRouter provides a unified API for accessing hundreds of AI models through a single interface. It follows an OpenAI-compatible API style, so applications using the OpenAI SDK can often switch to OpenRouter with very little code change. (OpenRouter)

What is OpenRouter?

Think of OpenRouter as an AI gateway/router sitting between your application and multiple LLM providers.

Instead of:

Your Application
      |
      +----> OpenAI
      |
      +----> Anthropic
      |
      +----> Google
      |
      +----> DeepSeek

you can have:

Your Application
      |
      v
  OpenRouter
      |
      +----> OpenAI
      +----> Anthropic
      +----> Google
      +----> DeepSeek
      +----> Meta
      +----> Other providers

Your application talks to one API, while OpenRouter handles access to the underlying models and providers.

It currently exposes hundreds of models through its API, and the available catalog can be queried programmatically. (OpenRouter)

Why would a developer use it?

The biggest advantage isn’t simply “many models.”

The real advantage is reducing coupling to a single AI provider.

Imagine your Rails application has:

MODEL = "some-expensive-model"

Six months later you discover that another model:

  • performs better for your use case
  • costs less
  • has better latency
  • has higher availability

With a direct provider integration, changing providers can involve SDKs, authentication, request formats, response formats and application-specific code.

With OpenRouter, the model is largely a configuration decision:

MODEL = "provider/model-name"

That makes experimentation much easier.

Practical Example: OpenAI-Compatible API

One of the most useful features is OpenAI API compatibility.

For example, using the OpenAI Ruby client, the important difference is the base_url:

client = OpenAI::Client.new(
  access_token: ENV["OPENROUTER_API_KEY"],
  base_url: "https://openrouter.ai/api/v1"
)

response = client.chat(
  parameters: {
    model: "provider/model-name",
    messages: [
      {
        role: "user",
        content: "Explain Ruby garbage collection."
      }
    ]
  }
)

puts response.dig("choices", 0, "message", "content")

The exact Ruby client API can vary by gem version, but the architectural idea is simple:

Keep your application code mostly unchanged and change the endpoint/model configuration.

OpenRouter officially documents using the OpenAI SDK with its API by changing the baseURL to the OpenRouter endpoint. (OpenRouter)

Which ruby gem to use?

1. The Recommended Path: The Official openai Gem (Drop-in Compatibility)

# AI assistant - OpenAI
gem "openai", "< 2.0"

Because OpenRouter mirrors OpenAI’s API structure, the easiest and most stable approach is to use the popular official-adjacent openai gem. You simply swap out the base_url and pass your OpenRouter API key.

My Current Rails Implementation is given below (Edited)

MODEL = "openrouter/free"
BASE_URL = "https://openrouter.ai/api/v1"
...
...
@api_key = Rails.application.credentials.dig(:openrouter, :api_key)
@client = OpenAI::Client.new(
      api_key: @api_key,
      base_url: BASE_URL
)

While OpenRouter does not maintain an official, first-party SDK exclusively for Ruby, its API is fully OpenAI-compatible. This gives you three simple ways to integrate OpenRouter into a Ruby application

Switching Models Becomes Cheap

Suppose you are evaluating three models:

models = [
  "openai/...",
  "anthropic/...",
  "google/..."
]

You can test the same prompt against different models without building three separate integrations.

This is particularly useful during development.

For example:

Task: Generate SQL query from natural language

Model A → Good accuracy, expensive
Model B → Very good accuracy, cheaper
Model C → Fast, acceptable accuracy

Instead of making a permanent decision immediately, you can benchmark them.

That’s a much better engineering approach than blindly choosing a model because it is popular.

Top models by task

check: https://openrouter.ai/rankings#task-spend

Automatic Fallbacks

This is one of the features I find particularly useful for production systems.

Suppose your primary model is temporarily:

Rate limited
        ↓
Provider outage
        ↓
Model unavailable

OpenRouter can automatically try another model/provider according to your routing configuration. (OpenRouter)

For example:

models: [
"primary-model",
"fallback-model-1",
"fallback-model-2"
]

If the first model fails, OpenRouter can attempt the next one.

This turns your AI integration from:

Application → One AI Provider

into something closer to:

Application
     |
     v
OpenRouter
     |
     +---- Primary
     |
     +---- Fallback
     |
     +---- Another fallback

For production applications, that resilience can be more important than simply having access to many models.

Provider Routing

There is another layer that is easy to overlook.

A model may be available through multiple providers.

OpenRouter can route requests between providers and allows developers to influence routing based on things such as provider order, price, throughput and latency. (OpenRouter)

For example, if your application cares primarily about speed, routing can be configured to prefer higher-throughput providers.

If cost is the priority, you can prioritize price.

That means your architecture can move from:

Use Model X

towards:

Use Model X
through the provider that currently makes the most sense

That is a much more interesting abstraction for production AI systems.

What About Cost?

OpenRouter doesn’t magically make every model free.

The underlying model still has its own pricing.

OpenRouter says it passes through provider pricing while providing unified billing and routing. (OpenRouter)

However, OpenRouter also exposes free models.

For example:

openrouter/free

is available as a free-model option, subject to the applicable limits. (OpenRouter)

This is particularly useful when learning or experimenting.

For example, instead of spending money while learning AI API integration:

Rails App
   ↓
OpenRouter
   ↓
Free/low-cost model

You can first build the feature, understand the API, streaming, prompts and error handling, and only later move to a more capable paid model.

Important: free does not mean unlimited. OpenRouter documents rate limits for free models, and those limits depend on account/credit conditions. (OpenRouter)

🏗️ A Good Architecture for Rails

For a Rails application, I wouldn’t scatter OpenRouter calls throughout controllers.

Instead, create an abstraction:

class AiClient
  def initialize
    @client = OpenAI::Client.new(
      access_token: ENV["OPENROUTER_API_KEY"],
      base_url: "https://openrouter.ai/api/v1"
    )
  end

  def ask(prompt)
    @client.chat(
      parameters: {
        model: ENV.fetch("AI_MODEL"),
        messages: [
          { role: "user", content: prompt }
        ]
      }
    )
  end
end

Then your application does:

response = AiClient.new.ask(
"Summarize this customer feedback"
)

The model becomes configuration:

AI_MODEL=provider/model-name

Now changing the model doesn’t require changing business logic.

That’s the pattern I would recommend for a production Rails application.

Where OpenRouter Makes the Most Sense

I would consider OpenRouter when:

1. You are experimenting with multiple LLMs

You don’t want to build five separate integrations just to compare models.

2. You want provider flexibility

Your application shouldn’t become tightly coupled to one AI company unless there is a strong reason.

3. You need fallback strategies

AI APIs can experience rate limits and provider outages. Model/provider fallback can improve resilience. (OpenRouter)

4. You are cost-conscious

You can compare models and route workloads according to cost/performance requirements.

5. You are building an AI abstraction layer

For example:

Rails Application
       |
       v
    AiClient
       |
       v
   OpenRouter
       |
   +---+---+---+
   |   |   |   |
  GPT Claude Gemini DeepSeek

Your business logic doesn’t need to know which provider actually processed the request.

Should You Always Use OpenRouter?

No.

There are situations where going directly to the provider makes more sense.

For example, if your application is deeply dependent on provider-specific features, you may want the official SDK/API directly.

Also, adding another layer means you should evaluate:

  • latency
  • provider availability
  • data/privacy requirements
  • supported API features
  • model-specific behavior
  • operational dependencies

OpenRouter also provides controls around provider selection and data collection, including options such as Zero Data Retention routing where supported, so these requirements should be evaluated rather than assumed. (OpenRouter)

My Take as a Senior Developer

I wouldn’t look at OpenRouter simply as “a website where I can access different AI models.”

The more interesting way to think about it is:

OpenRouter is an abstraction layer between your application and the rapidly changing LLM ecosystem.

The AI world is moving extremely fast.

Today’s best model may not be tomorrow’s best model.

If your application is tightly coupled to:

Application → Provider SDK → One Model

you have created an architectural dependency.

If instead you build:

Application
     ↓
AI Service / Adapter
     ↓
OpenRouter
     ↓
Multiple Models / Providers

you gain considerably more flexibility.

For me, model experimentation, provider independence, automatic fallback and a consistent API are the strongest reasons to consider OpenRouter.

And for someone learning AI development, it is also a practical way to experiment with different models without writing a completely different integration for every provider.

🔗 Useful References

Bottom line: If you’re building AI features today, don’t think only about which model to use. Think about how easily you can change that model tomorrow. OpenRouter is one practical way to design for that flexibility.

Happy Development!

Integrate AI with Rails: Day 10 – RAG Part 2: embeddings

Let’s move directly into RAG Part 2: embeddings.

One important correction before we code: the free model list you fetched earlier contains no free embedding model slug. OpenRouter currently lists liquid/lfm2.5-embedding-350m as a free embedding model, producing 1,024-dimensional vectors. OpenRouter’s embeddings API is OpenAI-compatible, so we can use the same Ruby SDK/base URL. (OpenRouter)

That means our existing vector(1536) column is the wrong dimension for the free embedding model we’ll use. We’ll fix that now.

RAG Part 2 – Ai::EmbeddingService

Our target architecture:

DocumentChunk
      │
      ▼
Ai::EmbeddingService
      │
      ▼
OpenRouter Embedding API
      │
      ▼
1024-dimensional vector
      │
      ▼
document_chunks.embedding

Then later:

User question
      ↓
Embedding
      ↓
pgvector similarity search
      ↓
Relevant chunks
      ↓
PromptBuilder
      ↓
LLM

Step 1 – Change the vector dimension

We originally created:

t.vector :embedding, limit: 1536

But our free model produces 1,024 dimensions.

Generate a migration:

bin/rails g migration ChangeDocumentChunkEmbeddingDimension

Open the migration and use:

class ChangeDocumentChunkEmbeddingDimension < ActiveRecord::Migration[8.1]
  def change
    remove_column :document_chunks, :embedding, type: :vector

    add_column :document_chunks, :embedding, :vector, limit: 1024
  end
end

Since our chunks don’t contain embeddings yet, removing and recreating the column is fine.

Run:

bin/rails db:migrate

Verify:

bin/rails dbconsole
\d document_chunks

You want:

embedding | vector(1024)

Then:

\q

Step 2 – Add the embedding model constant

Open:

app/services/ai/client.rb

Keep your existing chat models and add:

EMBEDDING_MODEL = "liquid/lfm2.5-embedding-350m:free"

So conceptually:

class Ai::Client
  MODELS = [
    "minimax/minimax-m3:free",
    "google/gemma-4-31b-it:free",
    "nvidia/nemotron-3-super-120b-a12b:free"
  ].freeze

  EMBEDDING_MODEL = "liquid/lfm2.5-embedding-350m"

  BASE_URL = "https://openrouter.ai/api/v1"

  # ...
end

Notice that this model is not a :free slug in the model ID you should send. OpenRouter currently lists this embedding model itself as free.

Check: https://openrouter.ai/models?output_modalities=embeddings


Step 3 – Add embeddings to Ai::Client

Add:

def embed(text:)
  response = @client.embeddings.create(
    model: EMBEDDING_MODEL,
    input: text
  )

  {
    embedding: response.data.first.embedding,
    model: response.model,
    input_tokens: response.usage&.prompt_tokens
  }
end

So your client now has two responsibilities:

chat()
embed()

Both communicate with the same OpenRouter endpoint, but use different models/endpoints. OpenRouter provides an OpenAI-compatible /embeddings API for this. (OpenRouter)

Step 4 – Test the raw embedding request

Open Rails console:

bin/rails c

Then:

client = Ai::Client.new

Now:

result = client.embed(
  text: "Ruby on Rails is a web application framework."
)

Inspect:

result.keys

You should get:

[:embedding, :model, :input_tokens]

Now:

result[:embedding].length

You should get:

1024

This is an important RAG checkpoint.

You’ve just proven:

text
embedding model
1024 numbers

Now inspect the first few values:

result[:embedding].first(5)

You’ll see floating-point numbers.

Don’t worry about the actual values. Their position in vector space is what matters.

Step 5 – Create Ai::EmbeddingService

Now we introduce the application-level service.

Create:

app/services/ai/embedding_service.rb

Use:

class Ai::EmbeddingService
  def initialize(ai_client: Ai::Client.new)
    @ai_client = ai_client
  end

  def call(text:)
    result = @ai_client.embed(text: text)

    result[:embedding]
  end
end

Why create another service when Ai::Client already has embed?

Because these are different responsibilities:

Ai::Client

How do I communicate with OpenRouter?

Ai::EmbeddingService

How does our application generate an embedding?

That distinction becomes useful once we introduce:

  • chunking
  • batch embeddings
  • document indexing
  • retries
  • persistence

Step 6 – Generate an embedding for a real chunk

We already created our Ruby Guide document.

Open console:

bin/rails c

Then:

chunk = DocumentChunk.first

Check:

chunk.content

Now:

embedding = Ai::EmbeddingService.new.call(
  text: chunk.content
)

Verify:

embedding.length

Expected:

1024

Step 7 – Save the vector

Now:

chunk.update!(embedding: embedding)

Then:

chunk.reload

And:

chunk.embedding.length

You should get:

1024

We now have our first actual vector stored in PostgreSQL.

Error: I cannot update embedding vector column with Ruby Array embedding data

I have tested to storing the embedding. But it seems to be Rails does not know / there is a Type mismatch for embedding ruby array data and db vector data type

➜  ai_assistant git:(main) ✗ rails c
Loading development environment (Rails 8.1.3.1)
ai-assistant(dev):001> chunk = DocumentChunk.first

embedding = Ai::EmbeddingService.new.call(
  text: chunk.content
)

> chunk.update!(embedding: embedding)
(ai-assistant):5:in '<compiled>': can't quote Array (TypeError)

          raise TypeError, "can't quote #{value.class.name}"
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Check the solution here: https://railsdrop.com/update-embedding-vector-column-with-ruby-array-embedding-data-from-llm/

Step 8 – Embed all three chunks

We currently have:

Chunk 1 → Ruby blocks
Chunk 2 → Ruby modules
Chunk 3 → Ruby classes

Run:

service = Ai::EmbeddingService.new

Then:

DocumentChunk.find_each do |chunk|
  chunk.update!(
    embedding: service.call(text: chunk.content)
  )
end

Now:

DocumentChunk.where(embedding: nil).count

should return:

0

And:

DocumentChunk.count

should return:

3

Step 9 – Verify directly in PostgreSQL

Run:

bin/rails dbconsole

Then:

SELECT
  id,
  chunk_index,
  vector_dims(embedding)
FROM document_chunks;

Expected:

 id | chunk_index | vector_dims
----+-------------+------------
 1  | 0           | 1024
 2  | 1           | 1024
 3  | 2           | 1024

This is a very useful RAG sanity check.

Step 10 – Now perform our FIRST semantic search

This is the exciting part.

Take a query:

"What allows Ruby code to be reused?"

Generate its embedding:

query_embedding = service.call(
  text: "What allows Ruby code to be reused?"
)

Now we need PostgreSQL to compare that vector against all the chunk vectors.

pgvector provides operators including cosine distance (<=>) and inner product; cosine distance is a common choice for semantic search. (OpenRouter)

Run this in Rails console:

results = DocumentChunk
  .where.not(embedding: nil)
  .order(
    Arel.sql(
      "embedding <=> '#{query_embedding}'"
    )
  )
  .limit(3)

Why we’re stopping at this exact point

We’ve now completed the embedding generation side:

Document
   ↓
Chunk
   ↓
EmbeddingService
   ↓
OpenRouter
   ↓
1024-d vector
   ↓
PostgreSQL

The next piece is the actual retrieval:

Question
   ↓
Query embedding
   ↓
pgvector
   ↓
ORDER BY cosine distance
   ↓
Top K chunks

That is the point where RAG becomes real.

Then we’ll build Ai::VectorSearchService and make the first semantic search against PostgreSQL – the most important practical RAG step after embeddings.


to be continued ..

Integrate AI with Rails: Day 10 – RAG with PostgreSQL + pgvector – part 1

We’ll move quickly, but this time keep each milestone runnable. Since you already have PostgreSQL and a working Rails 8.1 app, pgvector is a natural fit: it stores vectors alongside normal PostgreSQL data and supports cosine similarity plus exact and approximate nearest-neighbor search. (GitHub)

Step 13A – Install and enable pgvector

1. Check your PostgreSQL version

Run:

psql --version

Then check whether the extension is already installed:

bin/rails dbconsole

Inside PostgreSQL:

SELECT extname, extversion
FROM pg_extension
WHERE extname = 'vector';

If you get a row

For example:

 vector | 0.8.6

you’re ready.

If you get no rows

You need to install the extension on your PostgreSQL installation.

Since you’re on macOS, if PostgreSQL was installed via Homebrew:

brew install pgvector

The pgvector project currently documents Homebrew installation for PostgreSQL 17/18 formulas. (GitHub)

Then restart PostgreSQL if required by your installation:

brew services restart postgresql@14

Use your actual PostgreSQL version if different.

Step 13B – Enable pgvector in Rails

Once PostgreSQL has the extension available, exit psql:

\q

Generate the migration:

bin/rails generate migration EnablePgvector

Open the migration and use:

class EnablePgvector < ActiveRecord::Migration[8.1]
  def change
    enable_extension "vector"
  end
end

Then:

bin/rails db:migrate

Error: PG::UndefinedFile: ERROR: could not open extension control file "/opt/homebrew/share/postgresql@14/extension/vector.control": No such file or director

This error occurs because the pgvector extension is not installed or cannot be found in the directory of your specific Homebrew-managed PostgreSQL 14 installation.

Do:

# 1. Clone the pgvector repository
cd /tmp
git clone --branch v0.8.6 https://github.com/pgvector/pgvector.git
cd pgvector

# 2. Explicitly point to your PostgreSQL 14 pg_config binary
export PG_CONFIG=/opt/homebrew/opt/postgresql@14/bin/pg_config

# 3. Build and install the extension
make
make install # may need sudo

# Verify the Installation: after the installation completes successfully, check if the vector.control file is present in the target directory
ls /opt/homebrew/share/postgresql@14/extension/vector.control

Verify:

➜  ai_assistant git:(main) rails dbconsole
psql (14.17 (Homebrew))
Type "help" for help.

ai_assistant_development=# SELECT extname, extversion
FROM pg_extension
WHERE extname = 'vector';
 extname | extversion
---------+------------
(0 rows)

ai_assistant_development=#
\q
➜  ai_assistant git:(main) ✗ brew services restart postgresql@14
Stopping `postgresql@14`... (might take a while)
==> Successfully stopped `postgresql@14` (label: sh.brew.postgresql@14)
==> Successfully started `postgresql@14` (label: sh.brew.postgresql@14)
➜  ai_assistant git:(main) ✗ rails dbconsole
psql (14.17 (Homebrew))
Type "help" for help.

ai_assistant_development=# SELECT extname, extversion
FROM pg_extension
WHERE extname = 'vector';
 extname | extversion
---------+------------
 vector  | 0.8.6
(1 row)

You should now see vector.

Step 13C – Understand our RAG data model

We’re going to introduce two models:

Document
   │
   └── has_many :document_chunks

A document could be:

Ruby Guide

and chunks might be:

Chunk 1 → Ruby blocks
Chunk 2 → Classes
Chunk 3 → Modules
Chunk 4 → Metaprogramming

Each chunk gets its own embedding:

Chunk text
   ↓
Embedding API
   ↓
[0.021, -0.318, ...]
   ↓
PostgreSQL vector column

We’ll use 1536 dimensions initially, because we’ll use an embedding model that produces 1536-dimensional vectors. The actual dimension must match the embedding model you choose; pgvector requires the declared vector dimension to match stored vectors.

Step 13D – Create Document

Run:

bin/rails g model Document title:string source:string

Then:

bin/rails db:migrate

Open:

app/models/document.rb

Change it to:

class Document < ApplicationRecord
  has_many :document_chunks, dependent: :destroy

  validates :title, presence: true
end

Step 13E – Create DocumentChunk

Generate it:

bin/rails g model DocumentChunk \
  document:references \
  content:text \
  chunk_index:integer

Then don’t migrate yet.

We need to add the vector column manually because Rails’ generator doesn’t know which embedding dimension we want.

Open the generated migration and make it:

class CreateDocumentChunks < ActiveRecord::Migration[8.1]
  def change
    create_table :document_chunks do |t|
      t.references :document, null: false, foreign_key: true
      t.text :content, null: false
      t.integer :chunk_index, null: false
      t.vector :embedding, limit: 1536

      t.timestamps
    end

    add_index(
      :document_chunks,
      [:document_id, :chunk_index],
      unique: true
    )
  end
end

Depending on the pgvector Rails integration available in your environment, t.vector may not be recognized. If that happens, we’ll use:

add_column :document_chunks, :embedding, :vector, limit: 1536

instead.

The underlying PostgreSQL representation is:

embedding vector(1536)

which is the pgvector-native type.

Then:

bin/rails db:migrate

As expected gets the error:

-- create_table(:document_chunks)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

undefined method 'vector' for an instance of ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition

Do:

rails g migration addEmbeddingToDocumentChunks

# add
add_column :document_chunks, :embedding, :vector, limit: 1536

# do
rails db:migrate -t

Step 13F – Model association

Open:

app/models/document_chunk.rb

Use:

class DocumentChunk < ApplicationRecord
  belongs_to :document

  validates :content, presence: true
  validates :chunk_index, presence: true
end

Step 13G – Verify the database

Run:

bin/rails dbconsole

Then:

\d document_chunks

You should have:

ai_assistant_development=# \d document_chunks
                                          Table "public.document_chunks"
   Column    |              Type              | Collation | Nullable |                   Default
-------------+--------------------------------+-----------+----------+---------------------------------------------
 id          | bigint                         |           | not null | nextval('document_chunks_id_seq'::regclass)
 document_id | bigint                         |           | not null |
 content     | text                           |           | not null |
 chunk_index | integer                        |           | not null |
 created_at  | timestamp(6) without time zone |           | not null |
 updated_at  | timestamp(6) without time zone |           | not null |
 embedding   | vector                         |           |          |
Indexes:
    "document_chunks_pkey" PRIMARY KEY, btree (id)
    "index_document_chunks_on_document_id" btree (document_id)
    "index_document_chunks_on_document_id_and_chunk_index" UNIQUE, btree (document_id, chunk_index)
Foreign-key constraints:
    "fk_rails_99b41ada32" FOREIGN KEY (document_id) REFERENCES documents(id)

And:

SELECT vector_dims(
  '[1,2,3]'::vector
);

should return:

3

That proves the extension itself is working.

Exit:

\q

Step 13H – Create your first document manually

Before worrying about PDFs, parsers, Sidekiq, etc., let’s prove the RAG data model.

Run:

bin/rails c

Then:

document = Document.create!(
  title: "Ruby Guide",
  source: "manual"
)

Create chunks:

document.document_chunks.create!(
  content: "Ruby blocks are chunks of code passed to methods.",
  chunk_index: 0
)

document.document_chunks.create!(
  content: "Ruby modules allow code to be organized and reused.",
  chunk_index: 1
)

document.document_chunks.create!(
  content: "Ruby classes define objects and their behavior.",
  chunk_index: 2
)

Check:

document.document_chunks.count

Expected:

3

Step 13I – What we’ve built

Our database is now:

documents
----------------
id
title
source

        │
        │ 1 → many
        ▼

document_chunks
----------------
id
document_id
content
chunk_index
embedding

The crucial field is:

embedding

which will eventually contain:

[0.012, -0.883, 0.217, ...]

Int. Checkpoint

You should now be able to explain:

Why don’t we put the embedding on documents?

Because a document is usually too large to embed as one semantic unit.

We split it into chunks and embed each chunk independently:

Document
  ↓
Chunks
  ↓
Embeddings

That lets retrieval find the relevant section instead of returning the entire document.

One important design choice

We’re not adding an HNSW index yet.

An HNSW (Hierarchical Navigable Small World) index is a high-speed graph-based algorithm used to find similar items in large collections of high-dimensional data. It is widely used in vector databases for AI tasks like semantic search and recommendation systems.

pgvector supports exact nearest-neighbor search by default, and approximate indexes such as HNSW and IVFFlat become useful as the dataset grows. HNSW generally offers a strong speed/recall tradeoff but costs more memory and has a slower build.

IVFFlat (Inverted File with Flat compression) is a type of database index used to speed up similarity searches for high-dimensional vectors

For our small learning dataset:

exact search first

Once we have real embeddings and enough data:

HNSW index

We’ll deliberately compare both, which makes a good senior-level discussion.

We’ll create an Ai::EmbeddingService, generate a real embedding through our current provider setup, store it in PostgreSQL, and then perform our first semantic similarity search. That will be the point where we can honestly say we’ve built RAG mechanics rather than just knowing the definition.


to be continued ..