Ruby on Rails for beginners, and the tutorial that no longer runs
The hard part of a first week in Rails is not Rails. It is that the tutorial in one window and the application in the other are describing different frameworks, and nothing tells you which lines have moved. A page written for Rails 4 and a page written last month look identical: same syntax highlighting, same confident tone, no date anywhere near the code block. So the first hour goes on reconciliation rather than on learning, and the failure mode is believing the tutorial and concluding you have broken something.
Everything below was run against a fresh application generated with rails _8.1.3.1_ new blog,
running Rails 8.1.4 on Ruby 4.0.5, SQLite 3.53.2 and Puma 8.0.2, on an Apple M2 Max under macOS
arm64-darwin25. The outputs are pasted out of bin/rails test, bin/rails runner and
log/development.log. What that application contains before anybody writes a line is counted
separately in Rails for an MVP, counted; which course to follow is
Is Ruby on Rails hard to learn. This page is the narrower question
of which lines still execute.
Four lines from an older tutorial that now raise
Four idioms account for most of the confusion, because they were in every Rails tutorial for years
and they are gone. update_attributes survived longest: it is a deprecated alias at
activerecord-6.0.6.1/lib/active_record/persistence.rb:625, and the string does not appear anywhere
in activerecord-6.1.7.10. attr_accessible, before_filter and Model.scoped went earlier than
that, early enough that none of the three appears in the oldest releases still installed on this
machine, activerecord-5.2.8.1 and actionpack-5.2.8.1. All four now raise NoMethodError, which
is the good case: the error names the missing method and is searchable.
The test file below is the record. It lives at test/models/old_tutorial_idioms_test.rb in the
generated application, and its job is to assert what raises and what does not, so that the claims
on this page are executable rather than remembered.
require "test_helper"
# Every line in this file is a line a Rails tutorial still prints somewhere.
# The test records which of them run on this application and which do not.
class OldTutorialIdiomsTest < ActiveSupport::TestCase
setup { @post = posts(:one) }
test "update_attributes is gone and update is what replaced it" do
error = assert_raises(NoMethodError) { @post.update_attributes(title: "x") }
assert_match "undefined method 'update_attributes'", error.message
assert @post.update(title: "x")
end
test "attr_accessible is gone, strong parameters replaced it in the controller" do
assert_raises(NoMethodError) do
Class.new(ActiveRecord::Base) { attr_accessible :title }
end
end
test "before_filter is gone and before_action is the same thing renamed" do
assert_raises(NoMethodError) do
Class.new(ActionController::Base) { before_filter :authenticate }
end
assert Class.new(ActionController::Base) { before_action :authenticate }
end
test "Post.scoped is gone, Post.all returns the relation now" do
assert_raises(NoMethodError) { Post.scoped }
assert_kind_of ActiveRecord::Relation, Post.all
end
end
The exact messages, printed by bin/rails runner on the same application:
post.update_attributes(title:) NoMethodError: undefined method 'update_attributes' for an instance of Post
Post.scoped NoMethodError: undefined method 'scoped' for class Post
ActiveRecord::Base.attr_accessible NoMethodError: undefined method 'attr_accessible' for class #<Class:0x0000000126c9bfe0>
ActionController::Base.before_filter NoMethodError: undefined method 'before_filter' for class #<Class:0x0000000127199420>
One older idiom behaves worse than those four, and it is the one worth memorising.
Post.find(:all) is Rails 2 for "give me every row". It does not raise NoMethodError, because
find still exists and still takes an id, so Rails does the only thing it can with a symbol:
Post.find(:all) ActiveRecord::RecordNotFound: Couldn't find Post with 'id'=:all
A beginner reads RecordNotFound as a data problem, goes and checks the database, finds twenty
rows sitting there and loses the afternoon. The current line is Post.all.
Three lines that look just as old and still work
Age is not the test, which is the part that makes dating a tutorial hard. Three idioms that look exactly as antique as the four above are still supported on Rails 8.1.4, and rewriting them because they look old is wasted effort.
update_attribute, singular, is alive and still skips validations, which is the only reason to be
careful with it. With validates :title, presence: true declared on the model, the two methods
disagree, and the test says so:
# Post now declares: validates :title, presence: true
test "update_attribute, singular, is still here and still skips the validation" do
assert_not @post.update(title: "")
assert_equal [ "Title can't be blank" ], @post.errors.full_messages
assert @post.update_attribute(:title, "")
assert_equal "", @post.reload.title
end
Post.find_by_title("...") is a dynamic finder, a thing frequently described on the internet as
removed, and it answers. params.require(:post).permit(:title) works beside the newer
params.expect(post: [ :title ]) that the Rails 8.1 scaffold actually writes, and there is no
deprecation on the older form.
test "params.require().permit() still works beside params.expect()" do
params = ActionController::Parameters.new(post: { title: "t", admin: true })
assert_equal({ "title" => "t" }, params.require(:post).permit(:title).to_h)
assert_equal({ "title" => "t" }, params.expect(post: [ :title ]).to_h)
end
Both forms drop admin without a word, which is the behaviour to understand rather than the syntax;
what expect does differently when a key is missing is in
A Rails REST API, from rails new to the errors nobody shows you.
The one genuine trap in this group is :unprocessable_entity. It still resolves to 422, so a
controller copied from an older tutorial renders correctly, and it prints a warning to stderr while
doing it:
warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.
Rack::Utils.status_code(:unprocessable_entity) and
Rack::Utils.status_code(:unprocessable_content) both returned 422 here under Rack 3.2.7. The
scaffold writes the second one. Why that rename happened, and what else moved with it, is in
Ruby on Rails and Hotwire.
There is a fourth change in this family that no error message announces at all. belongs_to is
required by default, switched on by load_defaults at
railties-8.1.4/lib/rails/application/configuration.rb:121, under when "5.0". An association a
tutorial leaves unset therefore fails validation now instead of saving a null:
Session.create! with no user ActiveRecord::RecordInvalid: Validation failed: User must exist
How to date a Rails tutorial in ten seconds
Three paths and two constants settle the age of a tutorial without reading its prose, checked on the
generated application with File.exist? and defined?:
| Tell | In an application generated today |
|---|---|
app/assets/javascripts |
false |
config/secrets.yml |
false |
app/assets/config/manifest.js |
false |
defined?(Turbolinks) |
nil |
defined?(Sprockets) |
nil |
Rails.application.assets.class |
Propshaft::Assembly |
Dir.children("app/assets") |
["images", "stylesheets"] |
Dir.children("app/javascript") |
["application.js", "controllers"] |
A tutorial that tells you to edit app/assets/javascripts/application.js, or to add a gem called
turbolinks, or to put a key in config/secrets.yml, is describing an application you do not have.
That does not make the rest of it wrong, and throwing the whole page away over one stale path is an
overcorrection: the Active Record chapter of a 2018 tutorial is mostly still accurate, because
Active Record barely moved. The asset and configuration chapters are the ones that rotted.
bin/rake db:migrate is not on that list on purpose. Tutorials from before Rails 5 write rake
where current ones write bin/rails, and bin/rake db:version answered Current version:
20260927201849 on this application, so rake dates a page without breaking anything.
The three errors of a first hour, in full
Three errors arrive in the first hour of a Rails application for reasons that have nothing to do with the code being wrong, and all three are quoted here in full because a truncated error is unsearchable. The first one shows up the moment a generated model is used before its migration has run, and it is the most helpful error message in the framework:
ActiveRecord::PendingMigrationError (
Migrations are pending. To resolve this issue, run:
bin/rails db:migrate
You have 1 pending migration:
db/migrate/20260927201849_create_comments.rb
The response is a 500 in development, and it is a 500 on routes that have nothing to do with the
table in question: GET /postz, which is nothing but a typo, also answered 500 while that migration
was pending. That is how a typo and a forgotten migration turn into one confusing morning. After
bin/rails db:migrate, the same typo gives the error it should have given all along:
ActionController::RoutingError (No route matches [GET] "/postz"):
The third one waits until the first deploy. .gitignore ends with the line /config/*.key, and
the file that pattern catches is config/master.key, the one that decrypts
config/credentials.yml.enc. So the single file production cannot boot without is the single file
git was told never to carry. Moving config/master.key aside and starting the application in
production reproduces it exactly:
railties-8.1.4/lib/rails/application/configuration.rb:543:in 'Rails::Application::Configuration#secret_key_base=': Missing `secret_key_base` for 'production' environment, set this string with `bin/rails credentials:edit` (ArgumentError)
Nothing is corrupted when that happens and nothing needs regenerating. The key is on the laptop that
ran rails new, and it goes to the server as RAILS_MASTER_KEY.
The dead end: the Rails you generated with is not the Rails you are running
rails _8.1.3.1_ new blog pins the generator and does not pin the application, which cost real time
here before it became obvious. The Gemfile that command writes is:
gem "rails", "~> 8.1.3", ">= 8.1.3.1"
Bundler then resolves the newest release inside that range, and Gemfile.lock says rails (8.1.4).
bin/rails -v in the generated application answers Rails 8.1.4, not the 8.1.3.1 that was asked
for. For a beginner this matters for one reason only: when a version-specific answer on a forum
does not work, the version to check is the one in Gemfile.lock, and the one in the command history
is the wrong place to look.
The same confusion has a second source, and it is nastier because it is invisible. config/boot.rb
opens with ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__), so an inherited
BUNDLE_GEMFILE wins over the application's own. With that variable pointing at an unrelated
project, bin/rails -v in this application printed Rails 8.1.3.1 instead of Rails 8.1.4, from
inside the application directory, with no warning of any kind. What that same inheritance does to
rails new itself is the dead end documented in Rails for an MVP, counted, and
it is worth reading before generating an application anywhere near another Ruby project.
Read a tutorial with bin/rails console --sandbox open
The position this page takes is that a Rails tutorial should not be read in a browser alone. Every claim in one is checkable in about four seconds, and the console has a mode built for exactly this use, which rolls the session back on exit:
$ bin/rails console --sandbox
blog[development] 001 > Post.create!(title: "x", body: "y"); puts Post.count
21
nil
blog[development] 002 > exit
$ bin/rails runner 'puts "after sandbox: #{Post.count}"'
after sandbox: 20
Twenty-one rows inside the session, twenty after it. Nothing to clean up, so there is no reason to be careful, and being careful is what stops people from trying the thing they just read.
The cost is real and worth saying out loud. Checking as you go roughly doubles how long a tutorial takes, and it does not work at all for the chapters that need a browser, a payment provider or a deployed host. It also builds a habit that reads as slow to somebody watching. What would change this position is tutorials carrying a tested-against version in the header, the way the Rails Guides are versioned with the framework; until a blog post does that, its Rails version is something you infer from its file paths.
The scaffold fixtures both say MyString
test/fixtures/posts.yml, written by bin/rails generate scaffold, gives both rows the same values:
one:
title: MyString
body: MyText
two:
title: MyString
body: MyText
A test asserting Post.find_by_title(@post.title).id == @post.id fails against those fixtures, and
it failed here: Expected: 980190962, Actual: 298486374. The generated suite is a smoke test that
every route responds, not evidence that any of the logic is right, and reading it as the latter is
the most common misreading of a green first run.
What this page does not cover
Which learning resource to work through, and how hard the climb is from JavaScript, from another
framework or from no programming at all, is the subject of
Is Ruby on Rails hard to learn and is not answered here. What the
generators put on disk, and what a first product still needs after they run, is counted in
Rails for an MVP, counted. The difference between the language and the framework,
for anybody still unsure whether those are one thing or two, is
Ruby vs Ruby on Rails, and the SQLite default that rails new picks is
argued in What database Rails uses.
No throughput number appears on this page. The laptop it was written on was running other jobs
throughout, at a load average that read 3.38 4.22 4.01 across 12 cores when the last command
finished, and a performance figure measured on a machine in that state is not a figure anybody can
reproduce.
This list of moved idioms is also not exhaustive and cannot be. It is the set that showed up while generating one application, scaffolding one resource, generating one model and running the authentication generator, and every one of the checks in it is in the test file above rather than in anybody's memory.
Comments
No comments yet. Be the first.