Here is some example code from a Rails controller in the widely-used Discourse forum software:
class BadgesController < ApplicationController
skip_before_action :check_xhr, only: [:index, :show]
after_action :add_noindex_header
def index
raise Discourse::NotFound unless SiteSetting.enable_badges
badges = Badge.all
if search = params[:search]
search = search.to_s
badges = badges.where("name ILIKE ?", "%#{search}%")
end
if (params[:only_listable] == "true") || !request.xhr?
# NOTE: this is sorted client side if needed
badges = badges.includes(:badge_grouping)
.includes(:badge_type)
.where(enabled: true, listable: true)
end
badges = badges.to_a
user_badges = nil
if current_user
user_badges = Set.new(current_user.user_badges.select('distinct badge_id').pluck(:badge_id))
end
serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges, include_long_description: true))
respond_to do |format|
format.html do
store_preloaded "badges", serialized
render "default/empty"
end
format.json { render json: serialized }
end
end
# and more actions here...
end
Now, if you’re looking at this code coming from a JavaScript/TypeScript background—or a number of other programming languages—the first thing you might immediately think is:
Where are all the import statements??
That’s right, there’s nary an import statement to be found! Where does ApplicationController
come from? SiteSetting
? Badge
? Heck, even MultiJson
? How is this all just accessible without requiring it somehow?!
Ah my friend—welcome to the wonderful world of Ruby autoloading.
How to Acquire an Instinctual Hatred of Explicit Import Statements
Step 1: write Rails apps full-time for several years.
Step 2: go peek at the top of a file written for virtually any large NodeJS framework.
Step 3: 🤢
Look, I don’t mean to pick on poor JavaScript. When you’re trying to write performant code for eventual download to a browser where you need to keep the bundle sizes lean and mean, you want to import and export and tree-shake and chunk-split and do everything you can do to avoid megabytes of unnecessary code clogging up the wires.
But riddle me this: why do you need 20 import statements at the top of a file…in a server environment??
If you would indulge me for a moment, let’s imagine a world where you had to import all of the objects and functions needed in each file in your Rails application. Revisiting the example above, it might look something like this:
import ApplicationController from "./application_controller"
import { skip_before_action, after_action, params, respond_to, format } from "@rails/actionpack"
import Discourse from "../lib/global/discourse"
import SiteSetting from "../models/site_setting"
import Badge from "../models/badge"
import MultiJson from "@intridea/multi_json"
class BadgesController < ApplicationController
# etc...
end
And that’s just for a single controller action! 🤪
This leaves us with only one question: since your Ruby on Rails code obviously doesn’t have to import/require anything for it to work, how does it do that? How does it know how to simply autoload all these objects?
Introducing Zeitwerk
Actually, before we dive into Zeitwerk, let’s quickly review built-in Ruby autoloading.
Ruby comes out of the box with a form of autoloading attached to Module
. You can use this in any Ruby program you write:
# my_class.rb
module MyModule
class MyClass
end
end
# main.rb
module MyModule
autoload :MyClass, "my_class.rb"
end
MyModule::MyClass.new # this triggers the autoload
This is handy in a pinch, but for larger applications or gems and particularly for Rails, you need something that’s broader-reaching and more easily configurable—plus supports concepts like “eager loading” and “reloading” (in development).
That’s where Zeitwerk comes in.
With Zeitwerk, you can define one or more source trees, and within that tree, as long as your Ruby constants (modules and classs) and hierarchy thereof match the file names and folder structure via a particular convention, it all just works. Magic!
Here’s an example from the readme:
lib/my_gem.rb -> MyGem
lib/my_gem/foo.rb -> MyGem::Foo
lib/my_gem/bar_baz.rb -> MyGem::BarBaz
lib/my_gem/woo/zoo.rb -> MyGem::Woo::Zoo
And here’s how you instantiate a Zeitwerk loader. It’s incredibly easy!
loader = Zeitwerk::Loader.new
loader.push_dir("lib")
loader.setup # ready!
Once you’ve instantiated a Zeitwerk loader, at any point in the execution of your Ruby program after that setup is complete you can call upon any class/module defined within that loader’s source tree and Zeitwerk will automatically load the class/module.
In addition, if you use the loader.eager_load
method, you can load all of the code into memory at once. This is preferred in production for performance reasons: once your app first boots, it doesn’t have to load anything else later. On the other hand, in development you want to be able to reload code if it’s changed and run it without having to terminate your app and boot it up again. With the loader.reload
method, Zeitwerk supports that too!
You may be surprised to hear that Zeitwerk is somewhat new to the Ruby scene (Rails used a different autoloader before it and there have been other techniques in that vein over time). What makes Zeitwerk so cool is how easy it is to integrate into any Ruby app or gem. I myself am starting to integrate it into Bridgetown now. The only caveat is you do need to be a little strict with how you structure your source files and folders and what you name within those files. But once you do that, it’s a cinch.
Still a Use for require
Though
Even with Zeitwerk on the loose, you’ll still need to use a require
statement now and then to load Ruby code from a gem or some other random file you’ve pulled into your project. But the nice thing is that Ruby’s require
doesn’t work the way that import
does in JavaScript. It simply adds the requested file/gem to the current execution scope of your program and then it’s available everywhere proceeding from that point. So if you add require
to a main or top-level file within your application codebase, there’s no need to then “import Foo from "bar"
” later on in file B and “import Foo from "bar"
” in file C all over again.
This does mean that you may have to fish a bit to find out where MyObscureClassName.what_the_heck_is_this
actually comes from. This is likely how some of the “argh, Ruby is too magical!” sentiment out there arises. But given a choice between Ruby magic, and JS import statement soup at the top of Every. Single. Darn. File. In. The. Entire. Codebase…
…well, I believe in magic. Do you?
“Ruby is simple in appearance, but is very complex inside, just like our human body.”
matz
Subscribe to receive a timely tip you can apply directly to your Ruby site or application each week:
Banner image by Maksym Kaharlytskyi on Unsplash
Other Recent Articles

Ractors: Multi-Core Parallel Processing Comes to Ruby 3
Historically, the only way you could truly achieve async parallelism in Ruby would be to fork multiple processes or schedule background jobs. Until now.

Everything You Need to Know About Destructuring in Ruby 3
How improved pattern matching and rightward assignment make it possible to “destructure” hashes and arrays in Ruby 3.