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

Rails autoloading with Zeitwerk

A file can be wrong in a way that development never mentions and production refuses to start on. app/services/invoice_pdf.rb opening with module Billing instead of class InvoicePdf is the whole bug, and on this laptop it produced an HTTP 200 from bin/rails server and an exit status of 1 from RAILS_ENV=production bin/rails runner, with nothing in between to tell you which one you were about to ship.

Everything below ran on a Rails 8.1.3.1 application generated for this article, with zeitwerk 2.8.3, Ruby 4.0.5, macOS arm64-darwin25 on an Apple M2 Max. The timings at the end were measured against this site's own codebase, which is a different and much larger app. Every error string is pasted from the terminal.

Two loaders, and the difference between them is reloading

A Rails application does not have "a" Zeitwerk loader. It has two, and which one owns a directory decides whether the code in it is thrown away and read again on your next request.

Rails.autoloaders.each do |loader|
  puts "#{loader.tag}:"
  loader.dirs.sort.each { |dir| puts "  #{dir}" }
end
rails.main:
  <gem> activestorage-8.1.3.1/app/controllers
  <gem> activestorage-8.1.3.1/app/controllers/concerns
  <gem> activestorage-8.1.3.1/app/jobs
  <gem> activestorage-8.1.3.1/app/models
  app/controllers
  app/controllers/concerns
  app/helpers
  app/jobs
  app/mailers
  app/models
  app/models/concerns
  app/services
  lib
rails.once:
  <gem> importmap-rails-2.2.3/app/controllers
  <gem> importmap-rails-2.2.3/app/helpers

rails.main is the reloadable one. rails.once holds what an engine declared in config.autoload_once_paths, and in this app that is two lines of importmap-rails: config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers ) at lib/importmap/engine.rb:14. Code there is loaded once and never unloaded, which is the price a gem pays for being safe to reference from something that is itself never reloaded.

app/services is in that list and nothing in the generated app configured it. Every subdirectory of app becomes both an autoload root and an eager load path, from one declaration at rails/engine/configuration.rb:77:

paths.add "app",                 eager_load: true,
                                 glob: "{*,*/concerns}",
                                 exclude: ["assets", javascript_path]

lib is in the list too, which is new enough to still surprise people upgrading. It arrives from the config.autoload_lib(ignore: %w[assets tasks]) line the Rails 8 generator writes into config/application.rb, and it has a failure mode of its own further down this page.

A root directory is not a namespace

app/services/markdown_renderer.rb has to define MarkdownRenderer, not Services::MarkdownRenderer, because app/services is itself a root of the loader and roots map to Object. Put the wrong one there and you get the error this page is mostly about.

Directories under a root do namespace. app/services/billing/invoice_builder.rb defining Billing::InvoiceBuilder resolves, and Billing exists as a module even though no file anywhere defines it:

Billing::InvoiceBuilder.call = invoice
Billing = Billing  class=Module
Billing defined in a file? []
app/services/billing a root dir? false

Zeitwerk calls that autovivification, and loader/callbacks.rb:54 is the whole of it: implicit_namespace = cref.set(Module.new), inside on_dir_autoloaded. The practical consequence is that you never write a billing.rb holding an empty module, and if you do write one it takes over: the file becomes the definition and the directory adds children to it. The longer version of the roots-versus-namespaces argument, written for agent-generated code that gets this wrong more reliably than people do, is in conventions are the context.

The error names the file once, and then never again

Zeitwerk::NameError is raised at zeitwerk/loader/callbacks.rb:31, from on_file_autoloaded, and the message is the most useful thing Zeitwerk ever says to you. Reference the same constant a second time in the same process and it is gone:

2.times do |i|
  begin
    InvoicePdf
  rescue Exception => e
    puts "#{i}: #{e.class}: #{e.message}"
  end
end
0: Zeitwerk::NameError: expected file .../app/services/invoice_pdf.rb to define constant InvoicePdf, but didn't
1: NameError: uninitialized constant InvoicePdf

The reason is four lines above the raise, in a comment: "Ruby still keeps the autoload defined, but we remove it because the contract in Zeitwerk is more strict." cref.remove runs before the exception is constructed, so the autoload that carried the filename no longer exists. This matters when the first failure happens somewhere that swallows it, a rescue => e in a controller, a background job that retries, a health check. By the time a human looks, the process is only capable of producing the message with no file in it, and the obvious next move, grepping the codebase for a constant that is not defined anywhere, finds nothing.

Why the same file is green in development and fatal in production

config.eager_load is the entire difference, and it is false in development and true in production. In development nothing has looked at app/services/invoice_pdf.rb yet, so the mistake does not exist yet.

Here is the file, wrong, with a root route that never mentions it:

module Billing
  class InvoicePdf
    def self.render(invoice) = "pdf for #{invoice}"
  end
end
$ bin/rails server -p 3311
*  Environment: development
Completed 200 OK in 6ms (Views: 1.2ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.1ms)

$ curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:3311/
ok, and app/services/invoice_pdf.rb is still wrong
HTTP 200

The same tree, eager loaded:

$ bin/rails zeitwerk:check; echo "exit=$?"
Hold on, I am eager loading the application.
expected file app/services/invoice_pdf.rb to define constant InvoicePdf, but didn't
exit=1

$ SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production bin/rails runner 'puts "booted"'; echo "exit=$?"
.../zeitwerk/loader/callbacks.rb:31:in 'Zeitwerk::Loader::Callbacks#on_file_autoloaded': expected file .../app/services/invoice_pdf.rb to define constant InvoicePdf, but didn't (Zeitwerk::NameError)
exit=1

zeitwerk:check is not a linter and does not parse anything. Rails::ZeitwerkChecker.check opens on Zeitwerk::Loader.eager_load_all and the rest of it is bookkeeping, so the task is production's boot sequence with a friendlier error handler: the rake task rescues Zeitwerk::NameError and aborts with the message alone, stripped of Rails.root, which is why you get one line on stderr instead of the stack above. It costs about a second on a real codebase and it is the only thing standing between a rename and a failed deploy, so it belongs in the same script as your tests. bin/ci on this codebase runs it between RuboCop and RSpec.

A directory that is autoloaded and not eager loaded escapes all of it

config.autoload_paths << Rails.root.join("lab") adds a directory Zeitwerk will resolve constants from and nothing will ever eager load. Put the same misnamed file there and every check passes:

$ bin/rails zeitwerk:check; echo "exit=$?"
Hold on, I am eager loading the application.

WARNING: The following directories will only be checked if you configure
them to be eager loaded:

  lab

You may verify them manually, or add them to config.eager_load_paths
in config/application.rb and run zeitwerk:check again.

Otherwise, all is good!
exit=0

$ SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production bin/rails runner 'puts "prod booted fine"'
prod booted fine

Then the first request that touches it, in production, weeks later:

Zeitwerk::NameError: expected file lab/scratch_probe.rb to define constant ScratchProbe, but didn't

The warning exists because this is the only hole in the check, and it is worth reading rather than scrolling past. Use config.eager_load_paths << instead of config.autoload_paths << unless you have a reason, and the reason has to be better than "it boots faster", because the thing you bought is a naming error that surfaces in front of a user.

Acronyms: inflect, and know what the other fix costs

app/models/api_client.rb defining APIClient does not resolve, and the error is not the one you expect. Referencing APIClient gives you plain NameError: uninitialized constant APIClient, because Zeitwerk set an autoload for ApiClient and your constant was never in the table at all. Reference ApiClient and the real message appears.

Rails::Autoloaders::Inflector.camelize is @overrides[basename] || basename.camelize, so there are two places to intervene. The narrow one, in an initializer:

Rails.autoloaders.each do |autoloader|
  autoloader.inflector.inflect("api_client" => "APIClient")
end
APIClient.get: GET /x
camelize('api_client'): "APIClient"
String#camelize: "ApiClient"

The wide one is inflect.acronym "API" in an ActiveSupport::Inflector.inflections block. It also works, and it is doing something much larger:

APIClient.get: GET /x
String#camelize('api_client'): "APIClient"
String#camelize('api'):        "API"
'rapid_fire'.camelize:         "RapidFire"
'APIClient'.underscore:        "api_client"

"api".camelize is now "API" for everything in the process, including route helper generation, classify on table names and humanize in form labels. That is sometimes exactly what you want and it is never what you wanted from a fix to one filename. Take the acronym only if you are willing to own it site-wide; otherwise take inflect, which is a hash lookup keyed on the exact basename and touches nothing else.

lib is autoloaded now, and lib/generators will stop your build

The generated config/application.rb carries a comment asking you to add to the ignore list any lib subdirectory that should not be eager loaded, and names templates, generators and middleware. Here is what it is protecting you from. A three-line lib/generators/widget/widget_generator.rb:

class WidgetGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)
end
$ bin/rails zeitwerk:check; echo "exit=$?"
Hold on, I am eager loading the application.
bin/rails aborted!
NameError: uninitialized constant Rails::Generators (NameError)
exit=1

Not a Zeitwerk error, and the name in it is not yours, which is what makes it confusing: eager loading ran your file at a moment when Rails::Generators was not loaded, because a normal boot has no reason to load the generator stack. config.autoload_lib(ignore: %w[assets tasks generators]) fixes it, and the generator still works afterwards, because bin/rails generate finds generator files by path rather than through the autoloader. bin/rails generate --help still lists widget with the directory ignored.

A constant captured at boot answers the code the file used to hold

You cannot autoload from config/initializers at all. This is the documented rule in the Rails autoloading guide, "You cannot autoload code in the autoload paths while the application boots. In particular, directly in config/initializers/*.rb", and the mechanism behind it is an ordering you can print:

setup_once_autoloader      #35 of 192
load_config_initializers   #142 of 192
setup_main_autoloader      #179 of 192

So RENDERER = MarkdownRenderer in an initializer exits 1 with uninitialized constant MarkdownRenderer (NameError) and no mention of Zeitwerk, because at line #142 the main loader has not been set up and Object.autoload?(:MarkdownRenderer) is nil. The once loader is already up at #35, which is why a constant from an engine's autoload_once_paths works in an initializer and one of yours does not.

Defer the reference and the failure changes shape. after_initialize runs after #179, so it resolves, and then it is wrong in a way nothing reports:

Rails.application.config.after_initialize do
  Rails.application.config.x.renderer_once = MarkdownRenderer
end

Rails.application.config.to_prepare do
  Rails.application.config.x.renderer_each_time = MarkdownRenderer
end
before any reload
  after_initialize: equal?=true name="MarkdownRenderer" call="HI"
  to_prepare:       equal?=true name="MarkdownRenderer" call="HI"
after one reload
  after_initialize: equal?=false name="MarkdownRenderer" call="HI"
  to_prepare:       equal?=true name="MarkdownRenderer" call="HI"
after two reloads
  after_initialize: equal?=false name="MarkdownRenderer" call="HI"
  to_prepare:       equal?=true name="MarkdownRenderer" call="HI"

The held class still answers name with "MarkdownRenderer" and still answers call. It is not broken, it is old, and the symptom a developer actually reports is "I edited the file and the page did not change". Edit the file between reloads and both halves of that show up at once: the captured class returns "HI" from the previous version of the method while the constant returns "<em>hi</em>" from the file on disk, in the same process, in the same request.

The identity failures are the sharper ones. MarkdownRenderer.new.is_a?(captured) is false, so a case/when, a rescue SomeError on a captured exception class, and a hash keyed by class all stop matching after the first reload. Rails 6 used to raise A copy of X has been removed from the module tree but is still active! for roughly this situation. That string does not exist anywhere in activesupport 8.1.3.1 or zeitwerk 2.8.3, which I checked with grep before writing this sentence. The loud version is gone and the silent version is what you get.

No test you write will see any of it

bin/rails test forces the environment at rails/test_unit/runner.rb:44, ENV["RAILS_ENV"] = environment || "test", and config/environments/test.rb ships with config.enable_reloading = false. In that environment Rails.application.reloader.reload! leaves object_id exactly where it was, and Rails.autoloaders.main.unloadable_cpaths is empty no matter how many constants you have touched. A test asserting the stale-constant behaviour above passes trivially and proves nothing.

bin/rails test -e development is the escape hatch and it works: the same file, 7 runs, 22 assertions in the test environment and 25 in development, because three assertions only apply where reloading happens.

What it costs, on a real codebase

This site's own repository, which is where the numbers below come from rather than the toy app:

app autoload root dirs: 22, .rb files under them: 261
first  eager_load_all: 143.1 ms
second eager_load_all: 0.01 ms
main.unloadable_cpaths.size = 392

143 ms is the whole of what eager loading costs at boot here, on an M2 Max with a warm bootsnap cache, and bin/rails zeitwerk:check on the same repository took 0.95 to 1.01 seconds wall clock across three runs, almost all of which is booting Rails at all. Reloading has a separate cost that is not Zeitwerk's: watching the files. In a bind-mounted development container that is the number that hurts, not this one.

The dead end: require_relative

I went in expecting to be able to demonstrate the classic require_relative breakage, where a file in app/ requires a sibling, the sibling never gets an autoload after the first unload, and the second request in development dies on uninitialized constant. I could not make it happen on zeitwerk 2.8.3. app/services/rate_limiter.rb opening with require_relative "token_bucket" served three requests across two reloads, and both classes got a fresh object_id every time.

The reason is defensive code with a comment on it, at zeitwerk/loader.rb:179:

        else
          # Could happen if loaded with require_relative. That is unsupported,
          # and the constant path would escape unloadable_cpath? This is just
          # defensive code to clean things up as much as we are able to.
          unload_cref(cref)
          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
        end

So the file does get removed from $LOADED_FEATURES and the require_relative runs again on the next load. What the comment warns about is real and smaller than the folklore:

unloadable_cpath?("RateLimiter") = true
unloadable_cpath?("TokenBucket") = false

TokenBucket is reloaded in practice and is invisible to anything that asks Zeitwerk what it manages. Still do not write it. It is explicitly unsupported, the guarantee is one maintainer's "as much as we are able to", and it buys nothing that deleting the line does not.

The second dead end was mine and cost more time. I first tried to hold the boot-time class in Rails.application.config.x.renderer_at_boot and read it back in a controller. Every reading said the reference was stale, which was the result I was hoping for, and all of it was wrong: the initializer had been written after the server booted, initializers do not reload, and config.x's method_missing answers an unknown key with a fresh empty ActiveSupport::OrderedOptions rather than nil. So held.equal?(MarkdownRenderer) was false and held.name was nil because held was an OrderedOptions, not a stale class. A reader for whom config.x returns nil would have caught it on the first line; the auto-vivified default made a wrong experiment look like a right one for three runs.

The call, and what would change it

Run bin/rails zeitwerk:check in CI, before the tests, on every commit. It is one second and it is the only mechanism that turns a naming mistake into a red build instead of a failed deploy, because your test suite runs with eager_load false unless you set CI, and a controller spec does not reference the service object nobody has written the caller for yet.

Then keep config.eager_load_paths and stop reaching for config.autoload_paths. The gap between them is exactly the set of files zeitwerk:check prints a warning about and nobody reads.

For held references, the rule that survives contact is: capture nothing at boot, name it instead. A string in configuration that gets constantized at the call site is correct across every reload and costs a constant lookup per call. When you genuinely need the object, to_prepare, and accept that the block runs twice on boot and must be idempotent.

What would change the first two: a zeitwerk:check that ran as part of bin/rails test when eager_load is false, rather than as a task somebody has to remember. And on the third, a way to declare a configuration value as "a reloadable constant, resolve it per request" so the constantize is the framework's job rather than a convention nobody enforces.

What this post does not cover

Custom loaders. Everything here is about the two loaders Rails sets up; a gem or a script setting up its own Zeitwerk::Loader has a different lifecycle and Zeitwerk::Loader#setup semantics this page never touches. collapse and ignore on a loader, beyond the one autoload_lib(ignore:) case. Engines with their own autoload paths, which multiply the once-versus-main question by the number of engines. Thread safety and Zeitwerk::Loader#on_setup under a forking server. And config.autoloader = :classic, which railties 8.1.3.1 accepts in silence and does nothing with, covered where it belongs in the Rails upgrade path.

No claim here is made about Zeitwerk's behaviour on Ruby 3.x. Everything ran on Ruby 4.0.5, and the Kernel#require decoration that all of it rests on is the kind of thing that is version sensitive.

#rails #autoloading

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.