How Rails Autoloading Works With Zeitwerk
Learn how Rails autoloading works with Zeitwerk, including naming rules, eager loading, namespaces, and fixing NameError.
By Jean Emmanuel Cadet • 19 min read
If you have ever called User.find(1) in a Rails app without a single require line, you have already used Rails autoloading. Rails autoloading with Zeitwerk is why you can drop app/models/user.rb into your project and reference User from a controller, a job, or a view, with no manual loading.
It feels like magic until you meet Zeitwerk::NameError, or a class that works in development but breaks in production. Then you need to know the rules behind it. The good news is that the rules are small and consistent.
In this guide you will learn:
- What autoloading is and how Zeitwerk implements it
- How file names and directories map to Ruby constants
- How autoload paths, eager loading, and code reloading work
- How namespaces behave, including implicit and explicit ones
- How to fix
Zeitwerk::NameErrorand check your app withbin/rails zeitwerk:check
Here are the short answers first:
- Autoloading means Ruby loads a file the first time you reference the constant defined in it.
- Zeitwerk is the code loader Rails uses to do that, built on Ruby's own
Module#autoload. - File names must match constants because Zeitwerk derives the expected constant from the file path, and it has no other way to know what a file defines.
The examples target Rails 8 and work on Rails 7.x as well.
What Is Autoloading in Rails?
In plain Ruby, code in another file does not exist until you load it. That means require or require_relative:
require_relative "models/user"
require_relative "services/billing/invoice_creator"
User.new
This works, but it does not scale. A Rails app can have hundreds of models, controllers, jobs, and services, and nobody wants to maintain a list of require lines and keep them in the right order.
Autoloading flips the model. Instead of loading files up front, you tell Ruby where a constant lives, and Ruby loads the file the first time that constant is referenced. Ruby supports this natively with Module#autoload:
autoload :User, "/app/app/models/user.rb"
User # first reference: Ruby requires the file, then continues
User # second reference: already loaded, nothing happens
Rails does this for you at boot. It scans your application directories and registers an autoload for every constant it finds. When your code references Order, Ruby sees the registered autoload, requires app/models/order.rb, and carries on. You never write the require.
The part that makes this possible is a strict relationship between file paths and constant names. That relationship is what Zeitwerk enforces.
What Is Zeitwerk and Why Does Rails Use It?
Zeitwerk is a code loader for Ruby, written by Xavier Noria. It became the default autoloader in Rails 6.0, and Rails 7.0 removed the old autoloader completely. In a modern Rails app, Zeitwerk mode is the only mode, so you do not need config.autoloader = :zeitwerk anymore.
Rails uses Zeitwerk in two places:
Rails.autoloaders.mainmanages your reloadable application code.Rails.autoloaders.oncemanages code that should never be reloaded, if you configure any.
Why the old autoloader was replaced
Before Zeitwerk, Rails used a "classic" autoloader built on const_missing. When Ruby could not find a constant, Rails guessed which file might define it by searching the autoload paths. That approach had real problems:
- Results could depend on load order, so the same code could behave differently across runs.
- A constant could resolve to the wrong file when several similarly named files existed.
- Behavior could differ between development and production.
- You often needed
require_dependencyto nudge things along.
What Zeitwerk gives you
Zeitwerk replaces guessing with a fixed rule: the path of a file determines the constant it must define. Because the mapping is deterministic, you get:
- Predictable loading.
app/models/order.rbdefinesOrder. Always. - No
const_missingguesswork. Ruby's built-in autoload does the work, and it is thread-safe. - Reliable eager loading. Every file can be loaded up front and checked for correctness.
- Simple reloading. Zeitwerk knows exactly which constants it loaded, so it can remove them cleanly.
The trade-off is that you must follow the naming convention. In return, you never think about load order again.
How Zeitwerk Maps Files to Constants
This is the core of Rails constant loading, and it fits in one sentence: directories and files are converted from snake_case to CamelCase, and the file must define the constant that name produces.
The basic rule
app/models/user.rb -> User
app/models/user_profile.rb -> UserProfile
app/controllers/posts_controller.rb -> PostsController
app/jobs/send_welcome_email_job.rb -> SendWelcomeEmailJob
So app/models/user.rb must define User:
# app/models/user.rb
class User < ApplicationRecord
end
And app/models/user_profile.rb must define UserProfile:
# app/models/user_profile.rb
class UserProfile < ApplicationRecord
belongs_to :user
end
The file name is what Zeitwerk uses to register the autoload. That is why Rails file names need to match constants: the file is never opened to discover what it contains. Zeitwerk registers the autoload from the path alone, and it verifies after loading that the file really defined the expected constant. If not, it raises an error.
Nested directories become namespaces
Each subdirectory becomes a module in the constant name:
app/models/billing/invoice.rb -> Billing::Invoice
app/controllers/admin/users_controller.rb -> Admin::UsersController
app/services/payments/stripe_gateway.rb -> Payments::StripeGateway
A class inside a namespace looks like this:
# app/services/payments/stripe_gateway.rb
module Payments
class StripeGateway
def charge(amount_cents:, token:)
# call the Stripe API here
end
end
end
You do not create a Payments module by hand. Zeitwerk sees the payments directory and defines the Payments module automatically the first time it is referenced. That is called an implicit namespace, and we will come back to it below.
Root directories are not namespaces
Directories that are registered as autoload paths are "roots." Their own names are never part of a constant. This is why app/models does not produce a Models::User constant, and it also explains a common question about concerns:
app/models/concerns/trackable.rb -> Trackable
app/controllers/concerns/authenticatable.rb -> Authenticatable
Rails treats app/models/concerns and app/controllers/concerns as roots too, so the constant is Trackable, not Concerns::Trackable.
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
before_create :set_tracking_token
end
private
def set_tracking_token
self.tracking_token = SecureRandom.hex(8)
end
end
How Rails Autoload Paths Work
An autoload path is a root directory that Rails hands to Zeitwerk. Everything inside it follows the file-to-constant rules above.
Default autoload paths
Every subdirectory of app is an autoload path by default. That includes the standard ones (app/models, app/controllers, app/helpers, app/jobs, app/mailers) and any you create yourself, such as app/services, app/queries, or app/policies. You do not need to register them. Create the directory, follow the naming rules, and it works.
You can list the current autoload paths at any time:
# bin/rails runner
puts Rails.autoloaders.main.dirs
Autoloading the lib directory
The lib directory is not autoloaded by default. Since Rails 7.1, one line in config/application.rb fixes that:
# config/application.rb
module MyApp
class Application < Rails::Application
config.load_defaults 8.0
# Autoload and eager load lib, except for non-Ruby-constant directories
config.autoload_lib(ignore: %w[assets tasks])
end
end
With that in place, lib/payment_gateway/client.rb maps to PaymentGateway::Client. The ignore list matters because directories like lib/tasks hold Rake files that do not define constants, and Zeitwerk would complain about them.
Custom autoload and eager load paths
To add a directory outside app, use config.autoload_paths or config.eager_load_paths:
# config/application.rb
config.autoload_paths << Rails.root.join("extras")
config.eager_load_paths << Rails.root.join("domain")
The difference is small but useful:
autoload_paths: constants load on first reference.eager_load_paths: constants also load at boot when eager loading is on. These are automatically autoload paths too.
For code you never want reloaded (rare), there is also config.autoload_once_paths. Most apps never need it.
Remember that every autoload path is a root, so its own directory name never becomes part of a constant. If you add extras as a path, then extras/report_builder.rb must define ReportBuilder, not Extras::ReportBuilder.
Autoloading vs Eager Loading
These are the two ways Rails can load your code, and understanding both explains most "works on my machine" surprises.
- Autoloading (lazy): a file loads the first time its constant is referenced.
- Eager loading: Rails loads every file in the eager load paths at boot, before serving any request.
What config.eager_load does
The setting lives in each environment file:
# config/environments/development.rb
config.eager_load = false
# config/environments/production.rb
config.eager_load = true
# config/environments/test.rb
config.eager_load = ENV["CI"].present?
The test environment file has been generated this way since Rails 7.1, so eager loading turns on automatically in most CI systems, which is what you want.
In practical terms, config.eager_load = true gives you:
- Faster first requests in production. Nothing has to be loaded on demand while a user waits.
- Better memory sharing. Preforking servers like Puma in cluster mode can share loaded code across workers.
- Thread safety at boot. Code is loaded once, in a single thread, instead of racing under traffic.
- Early failure. A misnamed file fails during boot instead of failing in front of a customer.
Why development and production behave differently
In development, eager loading is off. A file with the wrong constant name will not blow up until something references it, and if nothing references it, you might never notice. Meanwhile, files that do get referenced are reloaded when they change.
In production, eager loading is on, so Rails loads every file at boot. A single mismatched file raises Zeitwerk::NameError and the app refuses to start.
This is the classic scenario: everything looks fine locally, then a deploy fails. The fix is to run the eager load check before you deploy. We cover that in the zeitwerk:check section below.
Do not confuse it with Active Record's eager_load
Rails uses the word "eager" for a different feature too. config.eager_load is about loading Ruby files. Post.eager_load(:comments) is an Active Record method that loads associations with a JOIN. They are unrelated. If you are here for the second one, the guide on how to optimize Active Record queries in Rails covers includes, preload, and eager_load properly.
How Rails Reloads Code in Development
In development you edit a file, refresh the browser, and see the change without restarting the server. Zeitwerk makes that reliable.
Reloading is controlled by this setting:
# config/environments/development.rb
config.enable_reloading = true
Older apps may show config.cache_classes = false. That is the inverse form of the same idea.
Here is what happens when you save a file:
- Rails notices that a watched file changed, using its file watcher.
- Before the next request, Rails asks Zeitwerk to reload.
- Zeitwerk removes every constant it autoloaded from the main autoloader, using
remove_const. - It registers the autoloads again, exactly as it did at boot.
- Your next request references constants normally, and each file loads fresh from disk.
Because Zeitwerk tracked precisely what it loaded, it can unload precisely those constants. No guessing, no stale leftovers.
Code that must survive or re-run on reload
Reloading has one trap. If you keep a reference to a reloadable class in a place that is not reloaded, you hold a stale copy. After a reload, Order points to a new class object, but your stored reference still points to the old one.
Avoid this in initializers:
# config/initializers/settings.rb (avoid this)
Rails.application.config.x.default_model = Order
Store the name and resolve it when needed:
# config/initializers/settings.rb
Rails.application.config.x.default_model = "Order"
# Later, at runtime
model = Rails.application.config.x.default_model.constantize
If you need to run setup code that touches reloadable classes, use to_prepare. It runs once at boot and again after every reload:
# config/initializers/notifications.rb
Rails.application.config.to_prepare do
Notifier.register(Billing::InvoicePaidHandler)
end
Here Notifier is an example class from your own app. The point is that Billing::InvoicePaidHandler gets reloaded, so the registration must be repeated each time.
Namespaces: Directories, Modules, and Classes
Namespaces are where most intermediate developers get tangled, so it helps to be precise about how they map.
Implicit namespaces
An implicit namespace exists only because a directory exists. There is no billing.rb file. Zeitwerk sees the directory and creates the module for you.
app/models/billing/invoice.rb
app/models/billing/payment.rb
# app/models/billing/invoice.rb
module Billing
class Invoice < ApplicationRecord
self.table_name = "invoices"
end
end
Billing is an auto-generated empty module. Use this style whenever the namespace is only a container.
A useful detail: an implicit namespace can span several root directories. Zeitwerk merges them.
app/models/billing/invoice.rb -> Billing::Invoice
app/services/billing/refunder.rb -> Billing::Refunder
app/jobs/billing/reminder_job.rb -> Billing::ReminderJob
All three live in the same Billing module, even though they sit in different top-level directories. This is a clean way to group code by feature.
Explicit namespaces
An explicit namespace is a namespace that has its own file. The class or module is defined in a file, and a matching directory holds its nested constants.
app/models/order.rb
app/models/order/status_calculator.rb
# app/models/order.rb
class Order < ApplicationRecord
scope :recent, -> { where(created_at: 30.days.ago..) }
def status
StatusCalculator.new(self).call
end
end
# app/models/order/status_calculator.rb
class Order::StatusCalculator
def initialize(order)
@order = order
end
def call
return :paid if @order.paid_at.present?
:pending
end
end
Here Order is a real Active Record model, and Order::StatusCalculator is a helper that belongs to it. Zeitwerk handles the ordering: it loads order.rb first, and once Order is defined, it registers autoloads for everything inside the order/ directory.
If the recent scope is unfamiliar, the article on writing better queries with Rails scopes explains how to build them well.
The rule for explicit namespaces: the file must define the class or module, and the directory name must match. order.rb with an order/ directory is fine. order.rb with an orders/ directory would not connect.
Compact style vs nested style
Both of these are valid for Zeitwerk:
# Compact style
class Billing::Invoice < ApplicationRecord
end
# Nested style
module Billing
class Invoice < ApplicationRecord
end
end
They are not identical in Ruby, though. The compact style changes constant lookup. Suppose Billing is an explicit namespace with a constant:
# app/models/billing.rb
module Billing
TAX_RATE = 0.1
end
# app/models/billing/invoice.rb
class Billing::Invoice < ApplicationRecord
def tax
subtotal * TAX_RATE # NameError: TAX_RATE is not found here
end
end
With the compact style, Ruby does not look inside Billing when resolving TAX_RATE. Nesting fixes it:
# app/models/billing/invoice.rb
module Billing
class Invoice < ApplicationRecord
def tax
subtotal * TAX_RATE # works, Billing is in the lexical scope
end
end
end
This is not a Zeitwerk rule. It is a Ruby scoping rule. But it shows up often enough in namespaced Rails code that it deserves a mention. When in doubt, nest.
Common Zeitwerk Naming Mistakes
Most Zeitwerk errors come from a small set of mistakes. Here are the usual suspects, each with a fix.
Mistake 1: The file name and the class name do not match
# app/services/payment_processor.rb (wrong)
class PaymentService
def call; end
end
Zeitwerk expects PaymentProcessor here. Fix it by aligning the two:
# app/services/payment_processor.rb (correct)
class PaymentProcessor
def call; end
end
You can also rename the file to payment_service.rb. Either way, the two must agree.
Mistake 2: Plural file name for a singular constant
app/models/users.rb # defines User (wrong)
app/models/user.rb # defines User (correct)
Models are singular. Controllers are the usual plural exception, because the constant itself is plural (UsersController).
Mistake 3: Wrong directory for a namespace
# app/models/billing/invoice.rb (wrong)
class Invoice < ApplicationRecord
end
The path implies Billing::Invoice, but the file defines a top-level Invoice. Fix it:
# app/models/billing/invoice.rb (correct)
module Billing
class Invoice < ApplicationRecord
end
end
Mistake 4: Multiple top-level constants in one file
# app/services/report_builder.rb (wrong)
class ReportBuilder; end
class ReportFormatter; end
ReportBuilder is fine. ReportFormatter has no autoload registered, because no file named report_formatter.rb exists. Referencing it before report_builder.rb loads fails, and in production it may depend on load order. Give each top-level constant its own file:
app/services/report_builder.rb -> ReportBuilder
app/services/report_formatter.rb -> ReportFormatter
Nested helper classes are fine as long as they live under the parent's namespace, either in the same file or in the matching directory.
Mistake 5: Acronym casing
# app/services/api_client.rb (fails by default)
class APIClient
end
By default, api_client camelizes to ApiClient, not APIClient. Either rename the class to ApiClient or teach Rails about the acronym, which we cover in the inflections section.
Mistake 6: A non-constant file inside an autoload path
Zeitwerk expects every .rb file in an autoload path to define its matching constant. A script, a patch file, or a Rake helper dropped into app/ or an autoloaded lib/ will trigger an error. Move such files elsewhere, or tell the autoloader to ignore them.
Fixing Zeitwerk::NameError
Zeitwerk::NameError is the error you will see most often, and it is almost always a naming mismatch. It looks like this:
Zeitwerk::NameError: expected file /app/app/services/payment_processor.rb
to define constant PaymentProcessor, but didn't
Read it literally. It tells you three things:
- Which file Zeitwerk loaded
- Which constant it expected that file to define
- That the constant was not defined after loading
Step-by-step troubleshooting
- Compare the path to the constant. Convert the file path to a constant by hand.
payment_processor.rbbecomesPaymentProcessor. Then open the file and check the class name letter by letter. - Check the namespace. If the file is in
app/services/billing/, the constant should beBilling::Something, notSomething. - Check pluralization and casing.
InvoicesandInvoiceare different constants. So areApiClientandAPIClient. - Check for typos. A misspelled class name is a common cause.
- Ask the inflector. You can see exactly what Rails expects:
# bin/rails console
Rails.autoloaders.main.inflector.camelize("api_client", "")
# => "ApiClient"
- Run the full check.
bin/rails zeitwerk:checkreports every problem at once, instead of one at a time.
There is a sibling error you may see: NameError: uninitialized constant Billing::Invoice. That usually means Zeitwerk never registered the constant, often because the file is in the wrong directory, is misspelled, or lives outside any autoload path.
Handling Irregular Inflections
Sometimes the default conversion does not match the name you want. The most common case is acronyms. You have two options.
Option 1: Register an acronym
If you want api_client.rb to define APIClient, teach the inflector about API:
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "API"
end
Now "api_client".camelize returns "APIClient", and Zeitwerk follows Rails' inflector, so app/services/api_client.rb must define APIClient.
This setting is global. It changes every camelize call in your app, not only autoloading, so api_key becomes APIKey everywhere. Use it when the acronym is a real convention in your codebase.
Option 2: Override a single file name
If you only need to fix one or two names, configure the autoloader directly:
# config/initializers/autoloading.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector.inflect(
"html_parser" => "HTMLParser",
"ssl_client" => "SSLClient"
)
end
These overrides apply to the file or directory basename you list, and they do not touch the rest of your app. This is the safer choice when you want minimal side effects.
After changing inflections, restart the server. Inflection changes are not picked up by code reloading.
Do You Still Need require or require_dependency?
For application code, no.
If a file lives in an autoload path and follows the naming rules, Zeitwerk loads it when needed. Adding require "billing/invoice" or require_dependency "billing/invoice" is unnecessary and can cause confusing double-loading behavior. require_dependency belonged to the classic autoloader and is obsolete in Zeitwerk mode, so remove it from your application code.
Manual loading is still correct in a few situations:
- Standard library and gems. Use
require "json"orrequire "csv"as usual. Gems in your Gemfile are already loaded by Bundler. - Files outside autoload paths. For example, a standalone script or a vendored file.
- Files you deliberately exclude from autoloading. Consider a directory of override files that patch other classes and do not define a matching constant:
# config/initializers/overrides.rb
overrides = Rails.root.join("app/overrides")
Rails.autoloaders.main.ignore(overrides)
Rails.application.config.to_prepare do
Dir.glob("#{overrides}/**/*_override.rb").sort.each do |file|
load file
end
end
Here ignore tells Zeitwerk to leave the directory alone, and to_prepare loads the files on boot and after each reload. Notice that load is used, not require, so the files re-run on reload.
The rule of thumb: if it defines a constant that matches its path, let Zeitwerk load it. If it does not, decide explicitly how it should be loaded.
How to Check Zeitwerk Compliance with zeitwerk:check
The fastest way to find naming problems is the built-in check:
bin/rails zeitwerk:check
It eager loads your whole application and verifies that every file defines the constant its path promises. When everything is correct, you will see output like this:
Hold on, I am eager loading the application.
All is good!
When something is wrong, the command raises the same Zeitwerk::NameError you would see in production, with the file and expected constant.
Make this part of your routine:
- Run it after adding or renaming files.
- Run it after upgrading Rails or Zeitwerk.
- Run it in CI so a bad file name fails the build, not the deploy.
Here is a simple CI step:
# .github/workflows/ci.yml (excerpt)
- name: Check Zeitwerk compliance
run: bin/rails zeitwerk:check
Because the check eager loads everything, it catches the class of bug that only appears in production. It is one of the cheapest safeguards you can add to a Rails project.
Practical Debugging Steps for Autoloading Errors
When something breaks and the cause is not obvious, work through this list in order.
- Read the error message fully. Zeitwerk errors name the file and the expected constant. Half the time the answer is right there.
- Run
bin/rails zeitwerk:check. It surfaces all naming problems in one pass. - Confirm the directory is an autoload path.
# bin/rails runner
puts Rails.autoloaders.main.dirs
- Turn on autoloader logging. In development you can watch Zeitwerk register and load constants:
# config/environments/development.rb
Rails.autoloaders.log!
This prints every load and unload event, which is very helpful when you are not sure which file is being loaded.
- Test the constant in the console.
# bin/rails console
Billing::Invoice
Billing.constants
If Billing.constants does not list your class, Zeitwerk has not registered it.
- Check for stale code. Restart the server after changing autoload paths, inflections, or
config/application.rb. Reloading does not re-read them. - Try eager loading in the console.
# bin/rails console
Rails.application.eager_load!
If a file is misnamed, this reproduces the production failure locally.
- Look at what changed recently. A renamed file, a moved directory, or a new acronym in
inflections.rbis the likely culprit.
Autoloading Mistakes to Avoid
Beyond naming, a handful of habits cause trouble again and again.
Referencing reloadable constants in initializers. Code in config/initializers runs before the main autoloader is ready, so this fails:
# config/initializers/billing.rb (avoid this)
PROCESSOR = Billing::PaymentProcessor
Wrap it in to_prepare, or resolve the constant lazily.
Caching reloadable classes in long-lived objects. Storing a class in a constant, a global, or a memoized singleton leaves you with a stale class after reload. Store the class name as a string and call constantize when needed.
Adding require lines for app code. It works around the problem instead of solving it, and it can load a file twice. Fix the naming instead.
Depending on load order. With Zeitwerk you should never care which file loads first. If your code only works when file A loads before file B, something is wrong with the constant references.
Putting scripts and patches into autoload paths. Keep non-constant files out of app/ and autoloaded lib/ directories, or ignore them explicitly.
Skipping the production-style check. Development has eager loading off, so it hides errors. Make bin/rails zeitwerk:check a habit.
A concrete example of a well-structured service that follows every rule above:
# app/services/billing/refunder.rb
module Billing
class Refunder
def initialize(invoice)
@invoice = invoice
end
def call
ActiveRecord::Base.transaction do
@invoice.payments.each(&:refund!)
@invoice.update!(status: :refunded)
end
end
end
end
The file path is app/services/billing/refunder.rb, the constant is Billing::Refunder, and there is a single class per file. Nothing else is needed for Rails to find it. If you want to understand why the refund is wrapped in a transaction, the practical guide to Rails transactions explains how they keep related writes consistent.
Conclusion
Rails autoloading with Zeitwerk comes down to one idea: the path of a file decides the constant it must define. Once you accept that, everything else follows.
- Files map to constants by converting snake_case to CamelCase, and directories become namespaces.
- Every subdirectory of
appis an autoload path, and root directories never appear in constant names. - Autoloading loads code on demand in development, while eager loading loads everything at boot in production.
- Zeitwerk unloads and reloads exactly what it loaded, which makes development reloading reliable.
Zeitwerk::NameErroralmost always means a file name and constant name do not match.- Manual
requireis rarely needed for app code, andrequire_dependencyis obsolete. bin/rails zeitwerk:checkcatches production failures before they reach production.
Follow the naming conventions, run the check in CI, and autoloading stops being something you think about. When it does break, you now know where to look.