Rails Zeitwerk Autoloading: Fix NameErrors, Eager Loading, and the Classic Loader Migration
Rails Zeitwerk autoloading explained: fix NameErrors, understand eager loading gaps, migrate from Classic loader, and configure inflections. Full guide.
The call came at 2am on a Thursday. Not from a client this time, but from a Sentry alert on my phone that had followed a successful GitHub Actions deploy by exactly forty seconds: NameError: uninitialized constant Api::V2::PaymentGateway::StripeAdapter.
That constant existed. I had written it two weeks earlier. It had passed the full test suite on CI. Staging had never complained. We rolled back in four minutes. It took two more hours to understand why.
Development environments lazy-load constants on first access. Production eager-loads everything at boot. The StripeAdapter file had a subtle naming mismatch that Zeitwerk tolerated under lazy loading — because something else happened to require it first — but rejected when it tried to load every file unconditionally at startup. We had shipped a production NameError that no amount of test coverage had caught, because our CI environment also ran with eager loading off.
After nineteen years of Rails I have made this mistake, watched clients make it, and explained Zeitwerk’s rules more times than I care to count. This guide is what I wish had existed before that Thursday.
How Rails Zeitwerk Autoloading Works
Zeitwerk replaced Rails’ Classic autoloader in Rails 6.0 and became the only supported option from Rails 7.0 onwards. Classic autoloading used $LOAD_PATH lookups and const_missing hooks — flexible in ways that created subtle bugs in multithreaded environments, and fundamentally incompatible with thread-safe eager loading.
Zeitwerk is stricter and more predictable. The core rule: the file path relative to an autoload root must match the constant it defines, exactly.
Given app/models/user.rb, Zeitwerk expects the file to define User. Given app/models/admin/billing_report.rb, it expects Admin::BillingReport. One file, one constant, path dictates name.
Development mode lazy-loads: constants are loaded on first use. Production mode eager-loads: all files under autoload roots are loaded at boot. That gap is where most Zeitwerk bugs hide. A constant that works in development because it happens to be required before its use — through a chain that Zeitwerk cannot guarantee — will silently work in development and fail in production.
The fix for the StripeAdapter bug above was one character: the file was named stripe_adapter.rb inside app/services/api/v2/payment_gateway/, but the class declaration at the top read StripeAdaptor (a typo, the British spelling). Zeitwerk loaded the file in development because another service required it by name first, hiding the mismatch. Under eager loading, Zeitwerk scanned the file, expected StripeAdapter, found StripeAdaptor, and raised.
The NameErrors You Will Actually Encounter
Mismatched file name and constant
# app/services/stripe_payment_processor.rb
class StripePaymentProcessorService # WRONG
def charge(amount:, currency:)
# ...
end
end
Zeitwerk expects StripePaymentProcessor in this file because the file is named stripe_payment_processor.rb. When code elsewhere references StripePaymentProcessorService, Zeitwerk looks for stripe_payment_processor_service.rb, which does not exist, and raises NameError.
Fix: rename the file to stripe_payment_processor_service.rb, or rename the class to StripePaymentProcessor. Pick the one that matches how the rest of your codebase references it. Then run the checker:
bin/rails zeitwerk:check
This scans all autoload paths and reports every file where the constant defined does not match what Zeitwerk expects from the file name. Add it to your CI pipeline immediately. It takes under two seconds.
Namespace structure does not match directory structure
# app/models/billing_invoice.rb ← wrong location
class Billing::Invoice # Zeitwerk expects this in app/models/billing/invoice.rb
end
Zeitwerk reads the path to determine the expected constant. billing_invoice.rb at the root of app/models/ tells Zeitwerk to expect BillingInvoice. Under lazy loading, if something loads Billing::Invoice before Zeitwerk touches billing_invoice.rb, the mismatch goes unnoticed. Under eager loading, Zeitwerk processes billing_invoice.rb, expects BillingInvoice, finds Billing::Invoice, and raises Zeitwerk::Error.
The fix is to mirror your module hierarchy in your directory structure:
app/models/
billing.rb # defines module Billing (or leave it out — Zeitwerk creates the module)
billing/
invoice.rb # defines Billing::Invoice
line_item.rb # defines Billing::LineItem
Zeitwerk can create namespace modules automatically — you do not need a billing.rb file unless the module needs methods or includes.
require_dependency is gone
Classic autoloading relied on require_dependency to manage load order. Zeitwerk does not use it. Any require_dependency calls left after a Classic-to-Zeitwerk migration are dead weight at best and dangerous at worst:
# WRONG — don't do this with Zeitwerk
require_dependency "billing/invoice"
class OrdersController < ApplicationController
def create
order = Billing::Invoice.create!(order_params)
# ...
end
end
Remove every require_dependency call. Search your codebase:
grep -r "require_dependency" app/ lib/
If removing one breaks something, the underlying cause is a naming or structure issue to fix — not a load order to patch. Zeitwerk handles load order correctly when your file names match your constants.
Eager Loading: The Production Gap
The most operationally dangerous aspect of Rails Zeitwerk autoloading is the default configuration difference between environments:
# config/environments/development.rb
config.eager_load = false # Rails default in development
# config/environments/production.rb
config.eager_load = true # Rails default in production
With eager_load = false, constants are loaded on demand. Your test suite may exercise every code path without ever triggering a Zeitwerk violation, because the tests happen to load constants in an order where all names resolve correctly.
To reproduce the production behavior locally, run this in a Rails console:
Rails.application.eager_load!
Any Zeitwerk::Error raised by that call is a production NameError waiting to happen. Fix it before it ships.
The more reliable place to catch this is CI. Add a dedicated step to your workflow:
# .github/workflows/test.yml
- name: Verify Zeitwerk eager loading
run: bin/rails runner "Rails.application.eager_load!"
env:
RAILS_ENV: production
SECRET_KEY_BASE: "ci-placeholder-not-used"
DATABASE_URL: $
This step takes under five seconds and has prevented production NameErrors for me more than once. The GitHub Actions CI/CD guide covers the full workflow setup if you are not already running this kind of matrix.
Custom Autoload Paths
Rails adds app/* subdirectories to Zeitwerk’s autoload roots by default. To add your own:
# config/application.rb
module MyApp
class Application < Rails::Application
config.autoload_paths << Rails.root.join("lib/my_app")
end
end
With this configuration, lib/my_app/data_importer.rb should define DataImporter. A file at lib/my_app/importers/csv_importer.rb should define Importers::CsvImporter.
The most common trap is adding lib itself as an autoload root:
# DON'T do this
config.autoload_paths << Rails.root.join("lib")
With lib as a root, a file named lib/json.rb shadows Ruby’s standard library json gem. Every file in lib becomes potentially dangerous. Use a namespaced subdirectory: lib/my_app/, lib/domain/, lib/services/.
To inspect what Zeitwerk has registered at runtime:
# In a Rails console
Rails.autoloaders.main.dirs # all autoload roots
Rails.autoloaders.each(&:log!) # enable verbose loading output
Inflections: Teaching Zeitwerk Your Acronyms
Zeitwerk translates file names to constant names using ActiveSupport’s inflector. Common English patterns work automatically; domain acronyms and irregular names do not:
# Without configuration:
# app/services/html_parser.rb → Zeitwerk expects HtmlParser
# app/models/ttb_client.rb → Zeitwerk expects TtbClient
# app/models/oauth_token.rb → Zeitwerk expects OauthToken
Fix this in the inflections initializer, which both Zeitwerk and ActiveSupport respect:
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "HTML"
inflect.acronym "TTB"
inflect.acronym "OAuth"
inflect.acronym "API"
inflect.acronym "JSON"
end
With these declarations, Zeitwerk maps html_parser.rb to HTMLParser, ttb_client.rb to TTBClient, and so on. The inflect.acronym approach is preferable to Zeitwerk-specific inflections because it propagates consistently into classify, humanize, route helpers, and URL generation — anywhere ActiveSupport’s inflector is used.
For one-off mismatches that you cannot fix with an acronym rule, you can configure Zeitwerk directly:
# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector.inflect(
"my_oddly_named_file" => "MyOddlyNamedConstant"
)
end
Use this sparingly. If you have more than two or three of these, it usually means a file naming convention problem rather than an inflection problem.
Migrating from Classic Autoloading
If you are on Rails 6.x with config.autoloader = :classic and need to move to Zeitwerk for a Rails 7+ upgrade, the process is straightforward but methodical. I use the same steps on every inherited codebase I upgrade — covered in more depth in the Rails upgrade incremental strategy post.
Step 1: Run the checker and fix every violation.
bin/rails zeitwerk:check
For each reported file, rename the file to match the constant (preferred) or rename the constant to match the file. Do not work around violations by restructuring code at this point — fix the naming, nothing else.
Step 2: Remove all require_dependency calls.
grep -rn "require_dependency" app/ lib/ | sort
Delete every instance. Commit these deletions separately from the naming fixes so the diff is readable.
Step 3: Add eager-loading verification to CI.
Before switching the autoloader, verify that your current Classic-mode codebase passes the eager-loading check. This gives you a clean baseline.
Step 4: Switch the autoloader.
In Rails 6.x apps, this is done explicitly:
# config/application.rb
config.autoloader = :zeitwerk
In Rails 7.0+, Zeitwerk is the only option — config.load_defaults 7.0 sets it automatically. There is no config.autoloader knob to set.
Step 5: Run zeitwerk:check again and test with eager_load = true.
After switching, run the checker again — Zeitwerk’s rules are subtly different from Classic’s tolerance for certain patterns. Then test in staging with config.eager_load = true before production.
The migration rarely takes more than a few hours on a well-structured codebase. On a codebase with fifteen years of require_dependency scattered through controllers and three different conventions for where domain logic lives, budget a day. The RSpec testing patterns post has the spec infrastructure to catch regressions as you work through the migration.
Testing Zeitwerk Compliance in Your Spec Suite
Add a dedicated spec that runs the full eager loader:
# spec/autoloading_spec.rb
require "rails_helper"
RSpec.describe "Zeitwerk autoloader", type: :integration do
it "eager loads all files without error" do
expect { Rails.application.eager_load! }.not_to raise_error
end
end
One spec, one assertion, runs in under a second. Put it in spec/autoloading_spec.rb rather than burying it in a rails helper — it is important enough to be visible and it needs to run on every CI build. The single most effective guard against production NameErrors I have ever added to a Rails project.
FAQ
Why does my constant work in development but raise NameError in production?
Because config.eager_load defaults to false in development and true in production. With eager loading off, Rails loads constants lazily on first use. If your test suite or development session happens to trigger the correct load order, a misnamed file goes undetected. Eager loading exposes the mismatch because Zeitwerk loads every file unconditionally at boot. Reproduce it locally by running Rails.application.eager_load! in the development console before shipping.
What is the difference between autoload_paths and eager_load_paths in Rails?
autoload_paths tells Zeitwerk which directories to watch and load from lazily in development. eager_load_paths tells Rails which directories to load eagerly in production. In Rails 7+, adding to config.autoload_paths automatically adds to eager_load_paths — you do not need to set both. The only time you would set eager_load_paths directly is if you want a directory eager-loaded but not available for lazy autoloading in development, which is rare.
How do I handle constants defined dynamically or inside blocks?
Zeitwerk cannot autoload constants defined inside blocks, module_eval, or via const_set. For these cases, require the file explicitly at the top of the file that needs the constant: require "path/to/file". Dynamic constant creation is worth eliminating regardless — it is the kind of opaque load-order dependency that Classic autoloading’s flexibility used to mask. If you are generating classes dynamically at runtime, load the file that does the generation explicitly and ensure the generated constants follow consistent naming conventions.
How do I debug a Zeitwerk::Error in production when I cannot reproduce it locally?
First, run bin/rails zeitwerk:check — it will identify the offending file and the constant it expected to find. Then compare that to the actual constant defined at the top of that file. If zeitwerk:check reports no violations, enable Zeitwerk logging temporarily: Rails.autoloaders.each(&:log!) in an initializer, deploy to staging, and check the logs for which files are being loaded and in what order. The log output is verbose but the mismatch always appears within a few seconds of boot.
Upgrading a Rails app that’s throwing Zeitwerk errors and blocking your timeline? TTB Software has done this migration across codebases from every era of Rails. We know where the bodies are buried. Nineteen years of experience means the migration that looks like a week of work usually takes a day.
Related Articles
Rails API Versioning: URL Namespaces, Header Routing, and Graceful Deprecation
Rails API versioning done right: URL namespaces, Accept header routing, controller inheritance, and Sunset headers fo...
Solid Queue Recurring Jobs: Replace Whenever and Sidekiq-Cron in Rails 8
Solid Queue recurring jobs replace whenever and sidekiq-cron in Rails 8. Configure cron tasks, handle missed executio...
Rails Turbo Morphing: Real-Time DOM Updates with broadcasts_refreshes
Rails Turbo Morphing surgically patches the DOM on page refresh. Learn broadcasts_refreshes, scroll anchoring, and wh...