How Zeitwerk maps files to constants, why classic-mode habits break, and how to actually debug uninitialized constant errors in a real Rails monolith.
A Thursday afternoon at the creator-economy platform I was at. I was two days into chasing a NameError: uninitialized constant Billing::BMAReceiptValidator that only happened in production. Local boot was clean. CI was green. Staging was happy. Production threw the error on the first request that touched a particular controller, then sometimes stopped throwing it after a few minutes. I spent the morning blaming the deploy pipeline. I spent the afternoon blaming Sidekiq. The real culprit was a six-line inflection rule that nobody had touched in 18 months.
That’s the punchline. The story is longer.
This is the writeup of how Zeitwerk actually works inside a large Rails app, the failure modes I keep seeing in the wild, and the runbook I now reach for first when an autoload error shows up in production.
Zeitwerk is a loader. Give it a directory, it builds a map from filenames to constant names, and it loads the file the first time the constant is referenced. That is the whole product. The Rails magic that older engineers remember, the require_dependency calls, the ActiveSupport::Dependencies monkey patches, the per-request constant unloading, is all gone. Zeitwerk took its place around Rails 6 and became the only supported loader in Rails 7.
The mapping rule is strict. app/models/billing/receipt_validator.rb must define Billing::ReceiptValidator. Not BillingReceiptValidator. Not Billing::Validators::Receipt. Exactly the name you’d get if you took the path, dropped the autoload root, snake-cased to camel-cased, and joined with ::.
That strictness is the feature. Classic mode would happily autoload a file whose internal constant didn’t match the path, then bite you weeks later when something else tried to reopen the wrong namespace. Zeitwerk refuses, loudly, at boot.
Here is where I lost two days.
Most acronyms in our Rails monolith were folded into normal camel case. Url, not URL. Bma, not BMA. That follows the default Ruby convention. The inflection file looked like the standard Rails generator output, plus a handful of customs that had built up over the years.
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "API"
inflect.acronym "JWT"
inflect.acronym "SSO"
inflect.acronym "URL"
inflect.acronym "PDF"
end
That list looks innocent. It is not. Each acronym there changes how Zeitwerk maps every file containing that token. The file app/services/api_client.rb no longer maps to ApiClient, it maps to APIClient. Every class ApiClient in the codebase becomes a Zeitwerk mismatch from the moment that initializer loads.
In my case, somebody had added inflect.acronym "BMA" six months earlier when the Branded Mobile Apps domain was carved out, and then partially renamed files. Most ended in BMA. A few stragglers in older code paths still defined Bma constants. The stragglers worked locally because we ran eager_load = false in development and never hit those paths. Production eager-loads everything on boot, so the mismatched files were checked at startup, but the file in question had its own eager_load: false annotation inside a Rails engine. So production only blew up when an actual request finally referenced it.
The fix was a five-minute rename across four files. Finding it was forty-eight hours.
When an autoload error shows up, two commands tell you almost everything. Reach for these before you reach for git blame.
# config/environments/development.rb
config.after_initialize do
Rails.autoloaders.log! if ENV["ZEITWERK_LOG"]
end
Set ZEITWERK_LOG=1 and boot. Every file Zeitwerk maps, every constant it expects, every conflict it finds, prints to stdout. The first few hundred lines are noise. The actual mismatch is usually a single line that says something like expected Billing::BmaReceiptValidator, got Billing::BMAReceiptValidator. Once you see that line, you’re done.
The second command is the boot-time integrity check.
# Run locally before opening a PR that touches loader config
bundle exec rails zeitwerk:check
This eager-loads the entire app under Zeitwerk’s strict rules and surfaces every file-to-constant mismatch. It’s the equivalent of a type check for autoloading. We added it to CI as a required step. It costs about 40 seconds in our build and has caught an autoload regression about once a month since.
The biggest reason autoload bugs hide in development and explode in production is the eager-load setting.
# config/environments/production.rb
config.eager_load = true
config.cache_classes = true
# config/environments/development.rb
config.eager_load = false
config.cache_classes = false
In development, Zeitwerk only loads a constant when something asks for it. In production, it loads every file under the autoload paths at boot, then freezes the constant graph. If your inflection rules disagree with your filenames anywhere in the tree, production catches it. Development won’t, not until somebody types the right URL.
My rule now: anything that touches config/initializers/inflections.rb, anything that adds a new top-level directory to eager_load_paths, anything that touches config/application.rb’s loader config gets zeitwerk:check run locally before review. Not optional.
A different week at the same platform, a different repo. I was bouncing between two or three squads that quarter because I knew the older Rails apps better than most. One of the older repos in the portfolio was still on Rails 5.2 with classic-mode autoloading. Leadership wanted it on Rails 6.1 by the end of the quarter, which meant flipping the loader from classic to Zeitwerk.
The setup looked clean. The repo had config.autoloader = :zeitwerk ready to go behind a feature flag. I flipped it on a Tuesday morning.
The app booted. The first request 500’d with uninitialized constant Reports::DailyExport::CSVRow. The file existed at app/services/reports/daily_export/csv_row.rb. It defined Reports::DailyExport::CsvRow, lower-case sv. The classic loader had been silently treating CSVRow and CsvRow as the same constant for years because the codebase only ever referenced it from inside the same namespace. Zeitwerk refused.
First fix attempt: add inflect.acronym "CSV" to the inflections file. That broke seventeen other files that defined CsvParser, CsvWriter, and friends. Now I had eighteen mismatches instead of one.
Real fix: write a small script that walked every file under app/, parsed the leading class and module declarations, computed what Zeitwerk would expect, and produced a diff. We ran it against the whole repo. Twenty-three mismatches surfaced, mostly around CSV, URL, and an internal acronym for a regional code. We fixed them all in one PR, kept the inflection file as minimal as possible, and the loader flip went out clean the next day.
The script took an afternoon to write. Without it, I’d have been playing whack-a-mole with acronyms for a week.
A few rules I’ve kept since.
Keep inflections.rb short. Every acronym you add is a constraint on every file in the tree, forever. Add them only when the domain genuinely uses the acronym name and the renames are done first.
Put zeitwerk:check in CI. It is cheap. It catches the entire class of bug I just described. There is no reason not to.
Run with ZEITWERK_LOG locally the first time an autoload error confuses you. The log is verbose but the mismatch line is unmistakable.
Treat the loader config as a public API. Changes to eager_load_paths, autoload_paths, or inflections deserve PR review at the same level as a schema migration. They have the same blast radius.
bundle exec rails zeitwerk:check belongs in CI for every Rails app.Rails.autoloaders.log! is the debugger. Use it before git blame.Thanks for reading. If you’ve got thoughts, send them my way.