A case against init.luau
2026-03-25
On January 23rd of 2025, Roblox introduced a small but significant change to how Luau is able to "import" code (Modules). Mainly, it finally allowed for a more traditional way of "importing" code, through what they called "require-by-string".
1-- The old way
2local MyModule = require(script.Parent.MyModule)
3
4-- The new way
5local MyModule = require("../MyModule")
6-- Note how it's not "../MyModule.luau", the new syntax automatically takes care of the file extension.
7
For those not familiar with Luau, the require function is how you import code from other files. Importing code is useful for a variety of reasons, such as code organization, reusability, and separation of concerns.
The idea behind this change was to unify how code is imported in Luau. Historically, Roblox used require(Instance) (e.g., require(script.Parent.Module)), while standalone Luau used strings. This created a massive "compatibility gap". You couldn't copy-paste a Luau library from GitHub into Roblox without rewriting every single require statement.
It also ironed out many other language issues with the standalone Luau implementation of require. For example, previously require was relative to your current working directory (directory you ran the script from), which meant you were literally unable reliably to use libraries.
But...
If I were to leave you here, you'd think this was a total victory for the community. We got familiar syntax, better compatibility and more robust rules for the language.
But if you spend even 10 seconds scrolling through the announcement post, you'll see the reality was much messier.
One of the lead maintainers of Rojo, Dekkonot, was a very vocal critic of how a specific part of this change initially worked. Considering his position as a maintainer of a tool that favors this pattern, reading his feedback is very insightful. That said, there's something that stood out to me. Perhaps unintentionally, his criticism, valid as it was, illustrates exactly why the init pattern creates these knock-on problems.
But before we get into that, let's talk about init.luau.
init.luau, why?
Understanding why init.luau exists requires a bit of a history lesson about Roblox, the filesystem, and sync tools. If you're interested, I dive deeper into this in a previous blog post, but the TL;DR is:
- Roblox was never designed to work with a traditional filesystem
- Developers wanted to use traditional software development tools (e.g., Git, VSCode, etc.) to work on Roblox projects
- Sync tools (e.g., Rojo) were created to bridge the gap between Roblox and the traditional filesystem
This leads us to the main problem. Roblox can nest objects inside each other, but filesystems can't. And since Scripts are objects, this creates a problem:
1In Roblox, this is perfectly valid:
2- 📜 FooModule
3 - 📜 BarModule
4
5But, in a filesystem...
6- 📜 FooModule.luau
7- 📜 BarModule.luau
8
We cannot put BarModule.luau inside FooModule.luau since they're files, not directories. So, the Rojo maintainers came up with a simple hack: the init.luau pattern. If you want to nest any sort of code inside each other, you can create a folder for the parent, and put an init.luau file inside it. This file will be the "entry point", and it can require other files inside the same folder.
For example, the structure:
1- 📜 FooModule
2 - 📜 BarModule
3
Gets represented as:
1- 📂 FooModule
2 - 📜 init.luau
3 - 📜 BarModule.luau
4
The code in the previously-mentioned FooModule is now contained inside init.luau.
The problem with init.luau
Initially, you'd say this is a pretty good solution. It's a common pattern in other languages, and it does technically solve the problem of nesting code inside each other... but it's not.
It's confusing
If we're going through the effort of setting up a sync tool to use Git, this pattern is just confusing.
In big projects, it will be common to have nested structures. How many times will you find yourself looking for a specific "init.luau"? Or... Was it even called init.luau? Maybe it was a descendant, who knows!?
By design, the pattern forces you to look for both files and directories instead of individual Scripts.
It creates The Tab Hell
Imagine you have 5 files open in your favorite IDE... And they're all called init.luau. How do you know which one is which? You have to hover over each tab to see the file path, and even then, it's super frustrating to manually distinguish between them.
It creates a structural lie
Maybe it's just me, but when using a sync tool with Roblox I want the filesystem to closely represent what I'll get on Roblox. It just makes it much more intuitive to navigate and understand.
With init files, you lie to yourself:
- In the filesystem,
FooModuleis a directory with a siblinginit.luau. - In Roblox,
FooModuleis a Script with a childBarModule.
It's a refactoring nightmare
The init pattern forces you to choose early on if you ever intend your code to have descendants. If you start with a simple FooModule.luau and later realize you need to nest BarModule inside it, you have to refactor FooModule.luau into init.luau and move all the code around.
To avoid this, you might as well make everything an init.luau from the start... But then, what's the point of even supporting standalone files? Leave way to the init.luau revolution, I guess.
It's misused (on Roblox)
Depending on what other languages you've used, you might have a preconceived notion of what init files are for. If you've ever done anything in Python, you may argue that __init__.py presents the perfect analogy to init.luau.
But the Python __init__.py comparison is a trap. In Python, an init file is a gatekeeper; it marks a directory as a package and stays out of the way. You rarely live inside that file.
However, in Roblox, we've turned the gatekeeper into the landlord. It's not uncommon to see init.luau files holding all the primary logic. Rather than just "initializing a package", we are writing our entire systems inside a file that claims to be a folder.
If init files were only used for packages or library entry points, I'd be more content with it. But when the pattern encourages the entire codebase to become a nesting doll of init files, the abstraction tower collapses.
Require-by-string, part 2
So, coming back to the require-by-string change, I want to highlight these specific issues which made most people unhappy with the initial implementation:
- Sibling vs. Child: Roblox accounted for init files in a way that mimicked Python, driving away from the existing pattern.
- The "Up and Back Down" Tax: Since the new syntax didn't respect the existing init pattern, it also inherently provided a worse experience for accessing children. You had to go up a directory and then back down to access children, which many argued was a regression to the familiar
script.Childsyntax.
And I feel like this is where things get interesting. Remember when I mentioned Dekkonot's initial criticism at the start of this article? It was technically correct, the developer experience was genuinely clunky.
But why was it?
Roblox tried to follow the same rules as Python, Node.js, and almost every other major ecosystem. The "standard" behavior became unusable not because Roblox got it wrong, but because init.luau in its current form is a mess the standard was never built to accommodate.
The surrender
On December 5th, 2025, a Luau RFC was merged that effectively signaled a total surrender to the status quo.
The RFC not only acknowledged the community's favorite hack, but it codified it into the language's core resolution logic and added the @self alias to patch the hole that change created.
Why is this a bad thing? Because it introduces many caveats, it's a "feature" the RFC itself admits needs guardrails.
Renaming
If you rename a file to or from init.luau, you have to rewrite every single require() in it, because the resolution rules change based purely on the filename. That's a fragile system. A file's name shouldn't determine how its paths are interpreted.
@self
I did briefly gloss over the addition of @self before, but what is it?
It's essentially a special sort of alias. If you've ever worked in Node.js, you may be familiar with the @ symbol being used to denote package scopes (e.g., @angular/core or @my-org/my-tool). It gives you a very nice way to import from a specific location without needing to use complex relative paths. (e.g., instead of importing from "../../../somewhere/something", you import from "@lib/something").
In this case, @self is a special alias that points to the current module's directory. So, if you're inside an init.luau file and you want to require a sibling file, you have to use require('@self/sibling') instead of just require('./sibling'). Why? Because the resolution rules changed based on the filename, and now require('./sibling') would resolve to the sibling of the parent directory instead of the sibling of the current file.
Take for example the following structure:
1- 📂 FooModule
2 - 📜 init.luau
3 - 📜 BarModule.luau <- Sibling of the init file
4- 📜 BarModule.luau <- Sibling of the parent directory
5
Previously, you could just do require('./BarModule') inside init.luau to access the BarModule next to it (its sibling), because the resolution rules were consistent. But now, you have to do require('@self/BarModule') to access the sibling, and require('./BarModule') would actually access the BarModule that's a sibling of the parent directory.
They've introduced a special case into path resolution, and then introduced new syntax to escape that special case. That's two layers of complexity added over one hack!
Different files, different rules
The implications of this RFC is that now Luau has two different types of modules. One that follows the "standard" resolution rules, and one that follows the "init" resolution rules.
This double standard is not only confusing, but it also creates a weird inconsistency in the language. You have to be aware of which type of module you're in at all times to know how it behaves with the files around it.
For any new developer coming into the ecosystem, this is a nightmare.
The bigger picture
Think about the weight of this for a second. A third-party sync tool, created by the community to fix a filesystem limitation, became so dominant that the official language maintainers had to first rewire path resolution, and then add new syntax, just to handle its quirks.
When walking back and changing Luau (the general-purpose language) to accommodate this, the RFC left us with this great admission:
"On a filesystem, Rojo's behaviour is frankly very weird!"
— Luau RFC, Abstract module paths and init.luau
The solution under our noses
The irony of this entire situation is how avoidable it could've been. Really, the solution is not only robust, but it also doesn't forbid for init files to still exist.
A lesson from Rust
Rust is a relatively modern language known for making waves in the programming space. It's designed with a focus on safety, performance, and concurrency. If there's anybody you should be getting inspired by, it's Rust.
Before 2018, Rust modules had a similar pattern to Roblox's init. If you wanted a module with submodules, you had to create a directory with a mod.rs file inside it. This mod.rs acted as the entry point for the module, and you would put your submodules in the same directory. For example:
1- 📂 src
2 - 📜 main.rs
3 - 📂 network
4 - 📜 mod.rs
5 - 📜 bar.rs
6
Remind you of anything? The mod.rs file is basically the init.luau of Rust. It was just as common as the init.luau in Roblox, but it was often a common pain-point for the community. It was confusing, it created tab hell, and it was a refactoring nightmare.
So, in 2018, Rust introduced a new module system that completely removed the need for mod.rs. Now, you can simply create a directory for your module and put your submodules inside it without needing an entry point file. For example:
1- 📂 src
2 - 📜 main.rs
3 - 📜 network.rs
4 - 📂 network
5 - 📜 bar.rs
6
In this new system, network.rs is its standalone file. You're not editing a file that only gets its context from the directory above it. You're editing a file that is its own module, and it can be easily extended with submodules without needing to refactor anything.
How I implemented it
I am lucky enough to get some influence on things like these thanks to the fact that I maintain Azul, which I'd consider is my heavily-opinionated alternative to Rojo. (I wrote an article about it, if you're up to learning more)
While Azul isn't nearly as big as Rojo, it still has a decent userbase (if 70 stars on GitHub are anything to go by!) and it's used in some pretty big projects, so these changes still have an impact on a small side of the community.
When implementing how Azul syncs modules from and to Roblox, I wanted to be honest. By adopting the Rust-style Peer Pattern, I eliminated I got rid of the init problems entirely. Each file exists as its own module, and if it needs children, you simply give it a sibling folder with the same name. No refactoring, no moving code into an init file, and most importantly, no lying to the filesystem. It's a system where require('./Parent/Child') actually points to a child, both on your disk and in the engine.
Azul has been using this system for a while now, and I can confidently say that I've been much more happier with it than I ever was with the init pattern. It's much more intuitive, it's easier to navigate, and it doesn't create any of the problems that the init pattern does.
Conclusion
The init.luau pattern was a well-intentioned solution that came from necessity, but it introduces structural ambiguity that we shouldn't accept anymore. It created more problems than it solved, and it forced the community to adopt a confusing and frustrating way of organizing code.
The fact that Roblox had to change the language itself to accommodate this pattern is a testament to just how deeply ingrained it became in the community.
However, there are better ways to organize code that don't involve creating a structural lie or a refactoring nightmare. We just need to look at how other languages have solved this problem and learn from their mistakes.

