LaunchKit
← All posts
· 11 min read · by The LaunchKit team · 0 views

Ruby vs Ruby on Rails

People typing "ruby vs ruby on rails" into a search box, or the same question the other way round as "ruby on rails vs ruby", are usually trying to work out whether they are looking at one thing or two and whether they have to learn both. Two things, and yes. Is Ruby on Rails a language? No. Ruby is the language, with its own interpreter, its own syntax and its own standard library. Ruby on Rails is a pile of gems written in that language, which you install into a Ruby you already have. The comparison has the same shape as "Python vs Django" or "JavaScript vs Express", and every claim below was run on this laptop against Ruby 4.0.5 and Rails 8.1.3.1.

The difference between Ruby and Ruby on Rails fits in two sentences and the rest of this page is not a restatement of them. What is actually worth knowing is where the seam sits inside your own code, because in a Rails application the two are interleaved line by line and nothing in the editor marks the boundary.

The rails gem contains no Ruby

Installing rails does not install a thing called Rails. It installs a manifest. Here is the whole of the gem on this machine, with stderr dropped because this RVM install prints a page of already initialized constant RDoc::VERSION warnings on every gem invocation:

$ gem contents rails --version 8.1.3.1 2>/dev/null
/Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/rails-8.1.3.1/MIT-LICENSE
/Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/rails-8.1.3.1/README.md

A licence and a readme. No lib directory, no .rb file. What the gem does carry is its dependency list, and that list is the actual answer to what Rails is:

$ gem dependency rails --version 8.1.3.1
Gem rails-8.1.3.1
  actioncable (= 8.1.3.1)
  actionmailbox (= 8.1.3.1)
  actionmailer (= 8.1.3.1)
  actionpack (= 8.1.3.1)
  actiontext (= 8.1.3.1)
  actionview (= 8.1.3.1)
  activejob (= 8.1.3.1)
  activemodel (= 8.1.3.1)
  activerecord (= 8.1.3.1)
  activestorage (= 8.1.3.1)
  activesupport (= 8.1.3.1)
  bundler (>= 1.15.0)
  railties (= 8.1.3.1)

Twelve frameworks pinned to the same version, plus bundler. "Rails" is the name of the agreement that those twelve ship together. When somebody says a method is "in Rails", it is in exactly one of those directories, and knowing which one is most of knowing where to read.

The dependency that settles the language question is not in that list, it is in the gemspec:

$ ruby -e 'puts Gem::Specification.find_by_name("rails", "8.1.3.1").required_ruby_version'
>= 3.2.0

Rails declares a Ruby it needs. Ruby declares nothing about Rails. That asymmetry is the whole relationship, and it is why the question "should I learn Ruby or Rails" has an order in it rather than a choice.

Telling which half of a line is Ruby

Every Ruby method object knows where it was defined, and Method#source_location returns nil for methods implemented in C inside the interpreter. That makes a two line script the fastest way to settle any argument about whether something is language or framework. This is which_half.rb in full:

require "active_support/all"

[[String, :upcase], [String, :titleize], [Array, :sum], [Array, :second],
 [Hash, :dig], [Hash, :deep_merge], [Integer, :digits], [Integer, :days]].each do |klass, name|
  meth = klass.instance_method(name)
  where = meth.source_location&.first&.sub(%r{\A.*/gems/}, "") || "the Ruby binary (C)"
  puts format("%-16s owner=%-26s %s", "#{klass}##{name}", meth.owner, where)
end

Run under a Gemfile holding nothing but rails 8.1.3.1 and minitest:

$ bundle exec ruby which_half.rb
String#upcase    owner=String                     the Ruby binary (C)
String#titleize  owner=String                     activesupport-8.1.3.1/lib/active_support/core_ext/string/inflections.rb
Array#sum        owner=Array                      the Ruby binary (C)
Array#second     owner=Array                      activesupport-8.1.3.1/lib/active_support/core_ext/array/access.rb
Hash#dig         owner=Hash                       the Ruby binary (C)
Hash#deep_merge  owner=ActiveSupport::DeepMergeable activesupport-8.1.3.1/lib/active_support/deep_mergeable.rb
Integer#digits   owner=Integer                    the Ruby binary (C)
Integer#days     owner=Numeric                    activesupport-8.1.3.1/lib/active_support/core_ext/numeric/time.rb

Four pairs, and in each pair the two methods look identical at the call site. array.sum and array.second are the same number of characters and the same shade in the editor. One works in any Ruby script ever written and the other is a gem's monkey patch that vanishes the moment you paste the line into a plain .rb file.

The owner column carries a second distinction that matters when you go looking for the definition. String#titleize is owned by String itself, because Active Support reopened the class and defined the method directly on it. Hash#deep_merge is owned by ActiveSupport::DeepMergeable and Integer#days by Numeric, because those arrived through a module. Grepping the Rails source for class String finds the first kind and misses the second, which is the usual reason a search for a Rails method comes back empty.

Keep that snippet somewhere. "Is this Ruby or is this Rails" is a question that comes up once a week in a codebase you did not write, and it is the only question on this page that has a mechanical answer you can produce in four seconds.

What Active Support does to the core classes

Active Support is the gem that makes the boundary hard to see, because the Active Support core extensions exist to add methods to classes the language already ships. Counting them is four lines, and the same four will count any class you care about that is not in the table below:

before = {}
[String, Integer, Array, Hash, NilClass, Object].each { |k| before[k] = k.instance_methods }
require "active_support/all"
[String, Integer, Array, Hash, NilClass, Object].each { |k| puts "#{k} #{(k.instance_methods - before[k]).size}" }

On Ruby 4.0.5 with activesupport 8.1.3.1:

Class Methods added
String 68
Hash 66
Integer 60
Array 55
NilClass 21
Object 21

Inside a booted application the number is larger, because Rails loads more than Active Support. A rails new application pinned to 8.1.3.1 reports String.instance_methods.size of 256, against 182 for ruby -e on the same machine. That is 74 added, and the extra six over the Active Support figure are worth a look, because they are not all Rails. From bin/rails runner inside that generated application:

$ bin/rails runner '%i[titleize shellescape].each { |m| puts "String##{m} -> " + String.instance_method(m).source_location[0].sub(ENV["HOME"], "~") }'
String#titleize -> ~/.rvm/gems/ruby-4.0.5/gems/activesupport-8.1.3.1/lib/active_support/core_ext/string/inflections.rb
String#shellescape -> ~/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/shellwords.rb

shellescape is Ruby's own standard library, from shellwords.rb, which a Rails application ends up requiring through some gem in the chain. It is available in your controller and it has nothing to do with Rails. The same is true of debugger, which comes from the debug gem in the default Gemfile. "New method that is not in a plain script" is not a synonym for "Rails method", and source_location is what tells them apart.

The require that does not do what its name says

require "active_support" gives you no core extensions. This is the dead end worth writing down, because the library's name is also the name of the thing that does not happen when you require it:

$ ruby -e 'puts 2.days.ago'
-e:1:in '<main>': undefined method 'days' for an instance of Integer (NoMethodError)

$ bundle exec ruby -e 'require "active_support"; puts 2.days.ago'
-e:1:in '<main>': undefined method 'days' for an instance of Integer (NoMethodError)

The same error, word for word, with the gem loaded. Reaching for the specific file does not fix it either, and the failure moves somewhere much less obvious:

$ bundle exec ruby -e 'require "active_support/core_ext/integer/time"; puts 2.days.ago'
/Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/activesupport-8.1.3.1/lib/active_support/core_ext/time/zones.rb:15:in 'Time.zone': uninitialized constant ActiveSupport::IsolatedExecutionState (NameError)

      ::ActiveSupport::IsolatedExecutionState[:time_zone] || zone_default
                     ^^^^^^^^^^^^^^^^^^^^^^^^

The core extension did load. 2.days returns an ActiveSupport::Duration and prints 2 days under p, or 172800 under puts, since to_s on a Duration is its length in seconds. What fails is ago, which defaults its argument to Time.current, which reads Time.zone, which reads a constant that active_support/core_ext/integer/time never required. The core extensions are installed, the runtime behind them is not.

Two requires work. require "active_support" followed by the core extension file, or require "active_support/all" on its own. In an application neither matters, because Rails does it for you at boot. Outside an application, in a Rakefile or a one off script or a gem, it is the first wall you hit, and the NameError names a constant that appears nowhere in the code you wrote.

And require "rails" is not the shortcut it looks like:

$ bundle exec ruby -e 'require "rails"; puts defined?(ActiveRecord::Base).inspect; puts defined?(ActionController::Base).inspect; puts 1.respond_to?(:days)'
nil
nil
false

require "rails" resolves to railties-8.1.3.1/lib/rails.rb, not to anything in the rails gem, and railties is the part that assembles an application rather than the part that talks to a database. rails/all is the require that pulls the twelve frameworks in.

What loading the framework costs

Each layer has a price in files parsed and memory held. Measured with $LOADED_FEATURES.size and ps -o rss=, under a Gemfile containing only rails 8.1.3.1 and minitest, on Ruby 4.0.5:

What is loaded Files in $LOADED_FEATURES RSS
ruby -e, no bundler 56 17.4 MB
bundle exec ruby -e 155 26.0 MB
require "active_support" 209 27.0 MB
require "active_support/all" 588 42.8 MB
require "rails" 570 44.6 MB
require "rails/all" 1009 80.4 MB

The file counts are exact and they reproduce: run the same require twice and you get the same integer. The RSS column does not, and you should not trust its last digit. Repeating the whole table an hour later gave 26.3, 27.3, 43.1, 44.5 and 81.5 MB for the same five rows, so read it as "about a megabyte of slack" rather than as a measurement to three significant figures.

Boot time on an Apple M2 Max, 12 cores, macOS 26.5.1, bootsnap installed and its cache warm, best of five runs each with /usr/bin/time -p:

ruby -e 'nil'                     real 0.03
bin/rails runner 'nil'  (rails new)   real 0.44
bin/rails runner 'nil'  (this site)   real 0.74

The generated application holds 1615 files at 88.0 MB. The application this page is published from, with a Gemfile that has real work in it, holds 2856 files at 144.5 MB. Boot is the noisiest figure here: this site measured 0.81s on one pass and 0.74s on another, and a busy laptop will push it further. Take the shape rather than the digits. That gap between 0.03s and roughly three quarters of a second is the number you pay every time you run a single test, and it is the honest cost side of the framework. It buys 22 pieces of HTTP middleware in a production rails new, ending on run Vanilla::Application.routes, none of which you wrote.

Your model is still a Ruby class

ApplicationRecord is not a new kind of object. The class chain in a generated Rails 8.1.3.1 application is ApplicationRecord < ActiveRecord::Base < Object < BasicObject, and ApplicationRecord.ancestors.size is 78 against 4 for Class.new in a bare ruby -e. The 74 entries in between are modules, included the ordinary way, and every method they provide is subject to the ordinary Ruby lookup rules.

Which is why the Ruby you have to know is not the Ruby of a syntax tutorial. It is method lookup, modules and prepend, blocks and yield, and what self is in a class body. Those four are what ancestors, source_location and a Rails backtrace are made of.

Which one to learn first

Learn enough Ruby to read a backtrace, then learn Rails, and go back to Ruby when Rails confuses you. The concrete floor is small: you should be able to explain why ApplicationRecord.ancestors has 78 entries and what that implies about which save runs. Below that floor, Rails is a vocabulary list and every unexpected behaviour is magic.

The cost of that order is real, and it is that you will write Rails for months before you understand half of what you typed. A rails new application is 19 Ruby files and 436 lines, and every one of those lines calls into code you have not read. People who dislike that feeling should take the other order and spend a few weeks on plain Ruby first.

What would change this position: if you are not building a web application. Ruby for scripting, for data plumbing, for a CLI or for a gem has nothing to do with Rails, and learning Rails first in that case teaches you conventions that will be actively in your way.

What this page does not cover

Rails against other frameworks is a different question and lives on Ruby on Rails vs JavaScript. Whether the framework is worth learning at all is Is Ruby on Rails hard to learn, and whether anyone is still hiring for it is Does anybody still use Ruby on Rails.

Nothing here is a measurement of runtime speed. The figures above are boot cost and memory at rest, measured on one laptop, with a warm bootsnap cache, on the versions named at the top. They say nothing about requests per second, and a cold cache or a Linux box will give you different numbers. There is also no coverage of JRuby, TruffleRuby or any Ruby other than CRuby 4.0.5, and no coverage of what Ruby 3.x reports for the same calls, which differs at least in the wording of NoMethodError.

#ruby #rails

Comments

No comments yet. Be the first.

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

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