Understanding Zeitwerk — Rails' Autoloading Engine, Explained
uninitialized constant Billing::PDFGenerator on a file that unmistakably exists at app/services/billing/pdf_generator.rb. Or a class that loads fine in development and vanishes in production. Or a change to a model that doesn’t take effect until you restart the server. All three are the same thing: a mismatch between what Zeitwerk expects and what’s on disk. The rules are strict, few, and worth learning once.
The Core Idea
Zeitwerk maps constant names to file paths, deterministically, in both directions. It does not scan files looking for definitions. It computes the file path from the constant name you referenced and loads that exact file.
Example:
app/models/order.rb → Order
app/models/billing/invoice.rb → Billing::Invoice
app/services/pdf_generator.rb → PdfGenerator
lib/api/v2/client.rb → Api::V2::Client
Every directory under an autoload root becomes a namespace. Every file becomes one constant, named by camelizing the filename.
Contrast with the old classic autoloader, which did the opposite — it took a missing constant, guessed at candidate paths, and used const_missing. That guessing is why classic had ambiguous resolution, thread-safety problems, and the infamous “works in development, breaks in production” class of bug. Zeitwerk removes the guessing entirely.
The Rules
1. One file, one constant, matching names
# app/services/invoice_builder.rb
class InvoiceBuilder # ✓ matches
end
# app/services/invoice_builder.rb
class InvoiceGenerator # ✗ Zeitwerk raises on eager load
end
The mismatch is caught at boot in production (eager loading) and at first reference in development. This is why a name mismatch can pass local testing and fail on deploy.
2. Directories are namespaces
app/services/billing/invoice_builder.rb → Billing::InvoiceBuilder
You don’t need a billing.rb defining the module — Zeitwerk creates it implicitly. If you do want to add methods to it, create app/services/billing.rb with module Billing; end and Zeitwerk uses yours instead.
3. app/* subdirectories are autoload roots, not namespaces
app/models/order.rb → Order (not Models::Order)
app/services/thing.rb → Thing (not Services::Thing)
app/anything/foo.rb → Foo
Every directory directly under app/ is added as a root path, so it doesn’t contribute to the namespace. Create app/decorators/ and it just works, with no configuration.
If you want the namespace, nest one level deeper:
app/services/billing/invoice_builder.rb → Billing::InvoiceBuilder
4. Acronyms need an inflection
This is the single most common failure.
app/services/pdf_generator.rb → PdfGenerator (default camelization)
If your class is PDFGenerator, Zeitwerk raises. Teach it the acronym:
Example:
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "PDF"
inflect.acronym "API"
inflect.acronym "HTTP"
inflect.acronym "OAuth"
end
Now pdf_generator.rb → PDFGenerator and api/v2/client.rb → API::V2::Client.
Note the knock-on effect: acronyms are global to the inflector, so declaring API also changes "api".camelize everywhere, including route helpers and to_s on some Rails internals. Declare them deliberately.
For a one-off that shouldn’t affect global inflection:
# config/initializers/zeitwerk.rb
Rails.autoloaders.main.inflector.inflect("pdf_generator" => "PDFGenerator")
Eager Loading Is Where Problems Surface
Development loads lazily — a file loads the first time its constant is referenced. Production eager-loads everything at boot.
That asymmetry means a naming violation in a file nobody references locally sits undetected until deploy. Catch it in CI:
Example:
bin/rails zeitwerk:check
Hold on, I am eager loading the application.
expected file app/services/pdf_generator.rb to define constant PdfGenerator
Add that to your CI pipeline. It takes seconds and catches the entire class of “worked locally” autoloading bugs before they reach a deploy.
The lib Directory
lib is not autoloaded by default in Rails 7+. Adding it needs care, because lib typically contains things that are not autoloadable — rake tasks, generators, templates.
Example:
# config/application.rb
config.autoload_lib(ignore: %w[assets tasks generators templates])
Everything in the ignore list is excluded from both autoloading and eager loading. Without it, Zeitwerk will complain that lib/tasks/import.rake doesn’t define Import.
Reloading in Development
Zeitwerk unloads and reloads constants when files change. Two rules govern what survives.
Never cache an autoloadable constant
# config/initializers/setup.rb
PROCESSOR = PaymentProcessor.new # ✗ holds a stale class after reload
After a reload, PaymentProcessor is a new class object. The instance in PROCESSOR belongs to the old one, and you’ll get baffling errors where processor.is_a?(PaymentProcessor) returns false.
Use a lazy reference instead:
Rails.application.config.to_prepare do
PROCESSOR = PaymentProcessor.new
end
to_prepare runs on every reload in development and once in production, which is exactly the semantics you want for anything holding a reference to application code.
Initializers run once
# ✗ registers against a class that gets replaced on reload
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
QueryLogger.record(*args)
end
Same fix — wrap in to_prepare, or reference the constant inside the block rather than capturing it.
Only autoloaded code reloads
Files in lib that you require manually, and anything in config/, don’t reload. If a change isn’t taking effect, check whether the file is actually under an autoload path — Rails.autoloaders.main.dirs lists them.
Custom Autoload Paths
Example:
# config/application.rb
config.autoload_paths << Rails.root.join("app/lib")
config.eager_load_paths << Rails.root.join("app/lib")
In Rails 7+, config.autoload_paths entries are not eager loaded automatically. Add both, or the code loads lazily in production too — which defeats the point and can cause thread-safety issues under Puma.
Use autoload_once_paths for code referenced from initializers that shouldn’t be unloaded on reload:
config.autoload_once_paths << Rails.root.join("lib/extensions")
Debugging a Failure
Ask the autoloader directly.
Example:
# What paths are managed?
Rails.autoloaders.main.dirs
# What would this file define?
Rails.autoloaders.main.cpath_expected_at("app/services/pdf_generator.rb")
# => "PdfGenerator"
# Where does this constant live?
Rails.autoloaders.main.__autoloads.find { |_, cpath| cpath.include?("PDFGenerator") }
# Turn on logging
Rails.autoloaders.log! # in an initializer or the console
cpath_expected_at answers the question directly: here’s the file, tell me what constant you’d demand from it. Comparing that string against what the file actually defines resolves most cases in one step.
Pro-Tip: When you hit
uninitialized constantand the file clearly exists, check for a nesting mismatch before anything else — it’s the failure that looks least like a naming problem. Zeitwerk resolvesBilling::InvoiceBuilderby loadingbilling/invoice_builder.rband then verifying the constant exists. If that file uses the compact formclass Billing::InvoiceBuilderandBillinghasn’t been defined yet, Ruby raisesNameErroron the namespace, not on your class, and the error names a constant you weren’t even referencing. Always use explicit nesting in autoloaded files —module Billing; class InvoiceBuilder— because it defines the namespace as a side effect and it also gives you the lexical scope that constant lookup inside the class depends on. The compact form additionally breaks resolution of sibling constants, which produces a second, unrelated-looking bug later.
Conclusion
Zeitwerk is a deterministic filename-to-constant mapping with no guessing, which makes it strict in exchange for being predictable. The rules that cover almost every problem: one constant per file with a matching name, directories under app/ are roots rather than namespaces, acronyms need an inflection, and anything holding a reference to application code belongs in to_prepare rather than an initializer. Run bin/rails zeitwerk:check in CI and the whole category of development-only autoloading bugs stops reaching production. Use explicit nesting rather than the compact class A::B form, and most of the remaining confusion disappears.
FAQs
Q1: Why does my constant work in the console but not in a request?
The console loads lazily and you may have referenced something in an order that happened to define the namespace. Reproduce with bin/rails zeitwerk:check, which eager loads everything in a defined order.
Q2: Can I have two constants in one file?
Only if the extras are nested inside the main one — class Order; class LineItem; end; end is fine in order.rb. Two top-level constants in one file will fail eager loading.
Q3: How do I exclude a directory from autoloading?
Rails.autoloaders.main.ignore(Rails.root.join("app/legacy")) in an initializer, or keep it out of the autoload paths entirely. Ignored code must be required manually.
Q4: What does to_prepare actually do?
It registers a block that runs before each request in development (after reloading) and once during boot in production. It’s the correct hook for anything that references application constants at load time.
Q5: Is there a performance cost to eager loading everything in production?
Boot time increases — seconds, on a large app. Runtime is faster and thread-safe, since nothing loads under a request. It’s the right trade for any multi-threaded server and it’s the default for good reason.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀