LaunchKit
← All posts
· 12 min read · by The LaunchKit team · 1 views

Rails 8.2, read from the source

A summary of "Rails 8.2" is circulating that describes a version released in early 2026 with native JSONB schema validation and Argon2id password hashing on by default. None of those three things is true, and the way to establish that takes about four minutes, because Rails publishes the release notes, the version file, the tags and the source, and all four disagree with the summary.

Everything below was checked against rails/rails at commit 7d52e01, dated 2026-09-25, running in a generated application on config.load_defaults 8.2 against PostgreSQL 17.7, Ruby 4.0.5, on an Apple M2 Max with 12 cores. Where a number appears, it came out of that application.

Rails 8.2 is 8.2.0.alpha, with no tag and no stable branch

Start with the version file, because it settles the question on its own:

$ cat RAILS_VERSION
8.2.0.alpha

railties/lib/rails/gem_version.rb agrees: MAJOR = 8, MINOR = 2, TINY = 0, PRE = "alpha". The generated app prints Rails.version as 8.2.0.alpha. Two more checks that need no clone:

$ git ls-remote --tags https://github.com/rails/rails.git | grep -E "v8\.[12]" | tail -2
6326130711fd92ac630f308e15399d0dace2570e  refs/tags/v8.1.4

$ git ls-remote --heads https://github.com/rails/rails.git | grep stable
9d525cb4308676f4c6e125086659eab16e2eae4c  refs/heads/8-0-stable
5acdd3804b1c92d37e1a93f6860f90395930f51a  refs/heads/8-1-stable

No v8.2.0 tag, no 8-2-stable branch. The rubygems API says the newest rails gem is 8.1.4, published 2026-09-24, two days before this page. A version that has a release date has a tag, and 8.2 has neither.

The release notes page itself is more candid than any summary of it. Section 2 is headed "Major Features" and contains nothing at all: the next line on the page is "3. Railties". The upgrade paragraph tells you to "first upgrade to Rails 8.1 in case you haven't" before "attempting an update to Rails 8.1", naming the same version twice, which is what an unedited draft looks like. The page never uses the words "alpha" or "unreleased" either, so the confusion is earned rather than invented.

The release notes page is not the inventory of Rails 8.2

Reading https://edgeguides.rubyonrails.org/8_2_release_notes.html end to end takes a few minutes, because there is not much of it. Nine of the framework's components carry entries, in ones and twos: three under Railties, one notable change under Action Pack, one under Action View, three under Active Record. Action Cable, Action Mailer, Action Mailbox and the Guides section have a Removals heading, a Deprecations heading, a Notable changes heading and nothing under any of them.

The real list of things that change under you is in a different file. config.load_defaults 8.2 is a when "8.2" branch in railties/lib/rails/application/configuration.rb starting at line 369, and it makes eleven assignments. Generating an app and reading them back:

Three files describe the same eleven default changes in Rails 8.2, and they carry different amounts of it. The release notes page names four of the eleven and never uses the string load_defaults. The when 8.2 branch in configuration.rb holds all eleven as bare assignments. The new_framework_defaults_8_2 template holds all eleven commented out, each with a paragraph explaining what flipping it does.

erb_implementation               = :herb
strict_accept_header             = true
raise_on_invalid_time_zone_parse = true
default_headers                  = ["X-Frame-Options", "X-Content-Type-Options",
                                    "X-Permitted-Cross-Domain-Policies", "Referrer-Policy"]
active_storage.analyze           = :immediately
enqueue_after_transaction_commit = true
default_protect_from_forgery_with = :exception
forgery_protection_verification_strategy = :header_only

plus rescue_from_event_backtrace = :array and the two PostgreSQL adapter flags, postgresql_adapter_decode_bytea and postgresql_adapter_decode_money.

Seven of those eleven have no entry on the release notes page. Grepping the rendered page for the identifiers returns zero for erb_implementation, strict_accept_header, decode_bytea, decode_money, rescue_from_event_backtrace, raise_on_invalid_time_zone_parse and X-XSS. The string load_defaults does not appear on the page either. So the notes tell you Active Storage analyzes attachments before validation, and do not tell you that your ERB compiler changed.

The file worth reading instead is railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_2.rb.tt, which is what bin/rails app:update writes into an upgrading application. Every one of the eleven is in there, commented out, with a paragraph explaining what flipping it does. That template is the document; the release notes page is a summary of the changelogs, and the changelogs are not where default changes live.

The CSRF default changes, and it is the one entry worth a whole page

forgery_protection_verification_strategy = :header_only is one of the eleven switches, and the one most likely to produce a 422 in production and a 200 on your laptop. A new 8.2 app reports ApplicationController.forgery_protection_verification_strategy as :header_only, which means protect_from_forgery stops consulting the authenticity token and reads one request header instead.

The branch that decides it is verified_via_header_only? at actionpack/lib/action_controller/metal/request_forgery_protection.rb:642, and the case worth knowing before you upgrade is the missing header: accepted over plain HTTP, rejected over TLS, which is the wrong way round for a bug you want to meet in development. Curling the same generated app twice, once as shipped and once with ActionDispatch::Http::URL.secure_protocol = true, gave 200 and then 422 for an identical request.

The strategies, the three deprecations that fire before you change any configuration, the clients that never send the header, and what same-site lets through are all in Rails 8.2 CSRF moves to Sec-Fetch-Site, measured against a real browser. The reason it belongs in this inventory at all: it is one of only four of the eleven default changes the release notes page does mention.

Herb compiles your HTML+ERB in a new 8.2 app

config.action_view.erb_implementation is :herb on 8.2 defaults, and ActionView::Base installs ActionView::Template::Handlers::ERB::Herb when it loads. Herb parses HTML and ERB as one tree, so an unclosed <div> becomes a compile-time error with a template location instead of a rendering oddity three screens down.

Rails ships a way to find out what breaks first:

$ bin/rails herb:check
Check that the application's HTML+ERB templates compile through Herb

Only the HTML format goes through Herb; every other format still compiles through Erubi, and erb_implementation = :erubi puts it back. For an application with several hundred partials written over a few years, run the check before the upgrade, not after.

Time.zone.parse raises on 8.2 where 8.1 returned nil

ActiveSupport.raise_on_invalid_time_zone_parse is true under load_defaults 8.2, and in the generated app Time.zone.parse("hello") raised ArgumentError: invalid date. Two days ago Rails time zones measured the same call returning nil on activesupport 8.1.3.1, and explained the early return in parts_to_time that produced it. That article is correct for 8.1 and wrong for an 8.2 app, which is the whole reason to pin versions in a sentence.

has_json casts values, it does not validate them

has_json is the Active Model addition that gets described as JSONB schema validation, and it is neither JSONB-specific nor validation. The implementation is activemodel/lib/active_model/schematized_json.rb, 125 lines, method_missing based, and the word jsonb does not appear in it. Its own documentation sets the ceiling: "Only the three basic JSON types are supported: boolean, integer, and string. No nesting either."

The assignment path is line 88, and it is the entire type system:

@data[key] = lookup_schema_type_for(key).cast(args.first)

ActiveModel::Type::Integer#cast is not a validator, so here is what a model declared as has_json :settings, max_invites: 10, greeting: "Hello!", beta: :boolean did with bad input:

"abc"      -> 0
"12abc"    -> 12
{deep: 1}  -> nil
[1, 2]     -> nil

valid? returned true after every one of those, with errors.full_messages == []. The Hash case is the dangerous one: the row held "max_invites": 100, an assignment of a Hash wrote "max_invites": null to PostgreSQL, and nothing anywhere raised or warned. Anything that reaches for has_json expecting a JSON Schema validator will get a column that silently zeroes out.

Two smaller edges. The predicate methods are @data[key].present?, so max_invites = 0 gives max_invites? of true, since 0.present? is true in Ruby. And the accessors are not real methods: settings.respond_to?(:max_invites) is true via respond_to_missing?, while settings.methods.include?(:max_invites) is false, which matters the moment you try to method(:max_invites) or memoize one.

Used as intended, for a settings blob assigned from form strings, it is a genuinely nice thing to have in the framework. Described as schema enforcement, it is a trap.

Argon2 needs a gem you do not have

The release notes entry says Argon2 arrives "via algorithm: :argon2", which is accurate and is not the same as a default. has_secure_password still resolves to BCrypt when no algorithm is passed, and writing has_secure_password algorithm: :argon2 without the gem fails at class load:

You don't have argon2 installed in your application. Please add it to your Gemfile and run bundle install.
cannot load such file -- argon2 (LoadError)

Everything else about it, including the parameters Rails picks, what happens to a table of existing bcrypt digests and the timing oracle a dual-read migration opens, is in Argon2 in has_secure_password, measured on this machine.

DROP DATABASE now carries WITH (FORCE) above PostgreSQL 13

drop_database in activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb appends one clause, guarded by supports_force_drop_database?, which is database_version >= 13_00_00:

statement = "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
statement += " WITH (FORCE)" if supports_force_drop_database?

With one psql session held open against the development database, the two statements behave differently:

$ psql -c 'DROP DATABASE IF EXISTS demo82_development'
ERROR:  database "demo82_development" is being accessed by other users
DETAIL:  There is 1 other session using the database.

$ bin/rails db:drop
Dropped database 'demo82_development'
Dropped database 'demo82_test'

bin/rails db:reset with a console open in another tab is the daily version of that error, and it is gone. The cost is that the footgun is also gone: db:drop now disconnects a colleague's console without asking, and on a shared staging database that is the wrong outcome. PostgreSQL 17.7 reported supports_force_drop_database? as true here.

The SQLite CASCADE fix already shipped in 8.1.3

The dead end, written down because it changes the advice. The 8.2 notes list a fix for "SQLite3 data loss during table alterations when child tables have ON DELETE CASCADE foreign keys", which sounds like a reason to want 8.2. I tried to reproduce the data loss on the newest released Rails, in a standalone script: parent table, child table with foreign_key: { on_delete: :cascade }, PRAGMA foreign_keys = 1, five child rows, then a change_column on the parent to force SQLite's twelve-step table recreation.

activerecord 8.1.4  sqlite 3.53.2
children before ALTER: 5
children after  ALTER: 5

Five rows, twice. The bug would not reproduce. Diffing the adapters explains why: the alter_table body in activerecord 8.1.3, 8.1.3.1, 8.1.4 and main is byte-identical, all four with disable_referential_integrity wrapping transaction rather than the reverse, and all four ship the changelog entry. The fix was backported and has been in the released 8.1 series since 2026-03-24.

So the correct reading of that release note is that 8.2 inherits a fix, not that it introduces one. If you are on SQLite and on any 8.1.3 or later, you already have it, and no upgrade is owed.

Time spent on that: about twenty minutes, most of it convincing myself the repro was wrong before checking whether the fix was already there. Check the released gem before believing an edge changelog entry is news.

Rails.app, and the 122 keys creds will hand you

Rails.app is alias :app :application at railties/lib/rails.rb:47, and Rails.app.equal?(Rails.application) is true. The two methods hanging off it are the interesting part.

Rails.app.revision resolves in a fixed order, and all four outcomes are worth knowing:

nothing present   -> nil
REVISION file     -> "deadbeefcafe"
ENV["REVISION"]   -> "from_env"      # wins over the file
git, nothing else -> "40627654709c7fcae2aa6f28d2c70cf9a26a424e"

The git case shells out, system("git", "-C", root, "rev-parse", "HEAD"), once per process. In a container built with COPY . . and no .git, and no REVISION written at build time, all three sources are absent and the value is nil, which is the case I hit first and the one your error reporter will hit too.

Rails.app.creds is an ActiveSupport::CombinedConfiguration over ENV, then .env in development only, then the encrypted credentials, first non-nil wins. Nested keys map onto a double underscore, which is not guessable:

ENV["DATABASE__HOST"] = "env.example.com"   ->   creds.option(:database, :host) == "env.example.com"
creds.require(:nope)                        ->   KeyError: Missing key: [:nope]
creds.option(:nope, default: -> { "lazy" }) ->   "lazy"

A .env file is parsed by ActiveSupport::DotEnvConfiguration, in development, with no gem. The release notes describe creds as combining "ENV or the encrypted credentials file" and do not mention dotenv at all, which undersells a feature people install a gem for.

The part to be careful with: the ENV backend contributes every variable in the process. Rails.app.creds.keys returned 122 entries on this laptop, including :ssh_auth_sock, :homebrew_prefix, :term_program and an unrelated project's API key from my shell profile. CombinedConfiguration#inspect prints that whole list, so a Rails.app.creds left in a console session, an exception page or a bug report is an inventory of every environment variable name on the machine. Values are not printed. Names often say enough.

Three that fit in a line each

implicit_persistence_transaction(connection, &block) has a one-line default, connection.transaction(&block), and overriding it is how you set an isolation level for every save on a model. The release notes call it "protected"; it sits after private at activerecord/lib/active_record/transactions.rb:454, and Account.private_method_defined? confirms it.

SecureRandom.base32 is Crockford base32 in uppercase, 16 characters by default, alphabet ("0".."9") + ("A".."Z") - ["I", "L", "O", "U"]. Running it gave WF4AB5T0V4V97PPV. For a code a human reads off a screen and types into a phone, that is the right alphabet.

parallelize(work_stealing: true) swaps RoundRobinDistributor for RoundRobinWorkStealingDistributor in activesupport/lib/active_support/testing/parallelization.rb. Round-robin assignment is now the default either way, which is the half of that change that matters: a test that fails only when it runs after another test now fails reproducibly.

What this page does not cover

Not covered: Active Storage's process: :immediately and the preprocessed: true deprecation, which need a real image pipeline to say anything useful about; the removal of the built-in sidekiq adapter and the deprecation of queue_classic, resque, delayed_job, backburner and sneakers, which is a one-line Gemfile change per application; the Action Text Trix deprecations; rendering a collection with a block; has_delegated_json, which is has_json plus generated top-level accessors; and postgresql_adapter_decode_bytea and decode_money, which change what select_value hands back on raw queries and deserve their own measurement.

Nothing here is a prediction about what 8.2 will contain when it ships. Every one of these can be reverted, renamed or dropped before a release candidate exists, and the entire point of the version string being 8.2.0.alpha is that the framework is telling you so. Re-read new_framework_defaults_8_2.rb at the release candidate rather than trusting this page, or any page, about a version that does not have a tag.

#rails #upgrades

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.