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.
Comments
No comments yet. Be the first.