___  ___   _  __________  __  ____      _____ _   ______
  / _ \/ _ | / |/ / __/ __ \/  |/  / | /| / / _ | | / / __/
 / , _/ __ |/    /\ \/ /_/ / /|_/ /| |/ |/ / __ | |/ / _/  
/_/|_/_/ |_/_/|_/___/\____/_/  /_/ |__/|__/_/ |_|___/___/  

homegamesother projectsblog

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). 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. 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 grimmer.

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 the tool that heavily favors this pattern, reading his feedback is very interesting. That said, there's something that stood out to me. Perhaps unintentionally, his criticism, valid as it was, illustrates exactly why the init pattern (in its current form) creates these knock-on problems.

But before we get into that, let's talk a bit 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

In a traditional filesystem, we cannot nest BarModule.luau inside FooModule.luau.

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 essentially represents the contents of its parent.

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 idea for this solution likely comes from base Lua itself, which has a similar pattern for modules. In Lua, an init.lua file represents the entry point of a module, which gives you better ergonomics when working with big modules/libraries. In principle, they work pretty similarly, but soon I will get into why I believe the way it is currently used on Roblox is a problem.

The problems of init.luau

Initially, this seems like a pretty clever solution. It allows us to represent nested structures in a filesystem.

However, once you become familiar with it in day-to-day use, you realize the glaring downsides of this pattern.

You lose context

The main appeal of Lua's init.lua is that it allows you to create a clear entry point for your modules. You can have a directory with multiple files, and the init.lua serves as the main file that ties everything together. This is especially useful for libraries, where you want to expose a clean API while keeping the implementation details hidden.

This is fine for libraries and other systems where you understand the trade-off between context and organization, which is how it works in the Lua world.

In Roblox, however, init.luau is used for everything. It's not just for libraries, it's for any Script that has children, which exacerbates the tiny context loss from the original Lua pattern. The liberal nature of Roblox's DataModel, which encourages nesting objects inside each other, means that the init.luau pattern becomes a default for most projects.

This frequent context loss is frustrating. When asking yourself what a file does, init.luau tells you too look elsewhere.

Naming

Naming files is as important as naming your variables and functions. A good name should be descriptive and intuitive. init.luau is neither of those. It doesn't tell you anything about what the file does, it just tells you that it's an entry point.

Again, in principle, good naming can be achieved with init.luau files, but in practice, it often isn't.

In big Roblox projects, it's common and expected to have deeply nested structures. The init pattern makes managing these structures a nightmare.

1+ init.luau
2+ class/init.luau
3+ class/types/init.luau
4- character/init.luau
5- services/init.luau
6

To drive this point home, let's see some real-world uses of init.luau in a real project:

1 ├── plugin/
2 │   ├── http/
3 │   │   ├── Error.lua
4+│   │   ├── init.lua
5 │   │   ├── init.spec.lua
6 │   │   └── Response.lua
7 │   ├── log/
8+│   │   ├── init.lua
9 │   │   └── init.spec.lua
10 │   ├── src/
11 │   │   ├── App/
12 │   │   │   ├── Components/
13 │   │   │   │   ├── Notifications/
14 │   │   │   │   │   ├── FullscreenNotification.lua
15+│   │   │   │   │   ├── init.lua
16 │   │   │   │   │   └── Notification.lua
17 │   │   │   │   ├── PatchVisualizer/
18 │   │   │   │   │   ├── ChangeList.lua
19 │   │   │   │   │   ├── DisplayValue.lua
20 │   │   │   │   │   ├── DomLabel.lua
21+│   │   │   │   │   └── init.lua
22 │   │   │   │   ├── StringDiffVisualizer/
23+│   │   │   │   │   ├── init.lua
24 │   │   │   │   │   └── StringDiff.lua
25 │   │   │   │   ├── Studio/
26 │   │   │   │   │   ├── StudioPluginAction.lua
27 │   │   │   │   │   ├── StudioPluginContext.lua
28 │   │   │   │   │   └── [...]
29 │   │   │   │   ├── TableDiffVisualizer/
30 │   │   │   │   │   ├── Array.lua
31 │   │   │   │   │   ├── Dictionary.lua
32+│   │   │   │   │   └── init.lua
33 │   │   │   │   ├── BorderedContainer.lua
34 │   │   │   │   ├── Checkbox.lua
35 │   │   │   │   ├── ClassIcon.lua
36 │   │   │   │   ├── [...]
37 │   │   │   ├── StatusPages/
38 │   │   │   │   ├── Settings/
39+│   │   │   │   │   ├── init.lua
40 │   │   │   │   │   └── Setting.lua
41 │   │   │   │   ├── Confirming.lua
42 │   │   │   │   ├── Connected.lua
43 │   │   │   │   ├── Connecting.lua
44 │   │   │   │   ├── Error.lua
45+│   │   │   │   ├── init.lua
46 │   │   │   │   └── NotConnected.lua
47 │   │   │   ├── bindingUtil.lua
48 │   │   │   ├── getTextBoundsAsync.lua
49+│   │   │   ├── init.lua
50 │   │   │   ├── [...]
51 │   │   ├── ChangeBatcher/
52 │   │   │   ├── createPatchSet.lua
53 │   │   │   ├── createPatchSet.spec.lua
54 │   │   │   ├── encodePatchUpdate.lua
55 │   │   │   ├── encodePatchUpdate.spec.lua
56 │   │   │   ├── encodeProperty.lua
57+│   │   │   ├── init.lua
58 │   │   │   └── init.spec.lua
59 │   │   ├── Reconciler/
60 │   │   │   ├── applyPatch.lua
61 │   │   │   ├── applyPatch.spec.lua
62 │   │   │   ├── decodeValue.lua
63 │   │   │   ├── diff.lua
64 │   │   │   ├── diff.spec.lua
65+│   │   │   ├── init.lua
66 │   │   │   ├── [...]
67 │   │   ├── [...]
68 │   ├── run-tests.server.lua
69 │   ├── test-place.project.json
70 │   └── [...]
71

(This is the codebase for the Rojo plugin, which otherwise is a pretty well-maintained and organized codebase.)

(Yes, these files are technically called init.lua. For brevity's sake, I will refer to them as init.luau in this discussion. Understand, however, that they are identical in function for Roblox projects.)

I want you to take a moment and scroll through this file tree. For every init file you see, I want you to ask yourself:

  • What does this file do?
  • Why is it here?
  • How does it relate to the files around it?

For a few the answers are obvious. For instance, http/init.lua must handle something related to HTTP requests.

But for most of them, we just plain don't know.

For instance, what does StatusPages/init.lua do? Does it handle the logic for all the status pages? Does it export utilities? Does it export the pages themselves?

Unless we were to open the files and read through them, we have no idea what they do. And that's a problem!

It creates The Tab Hell

I don't think I need to explain what happens when you have multiple files with the same name open in your IDE.

The tabs strip you of the context init.luau delegates to the directory.

In the previous example codebase, there are more than twelve files named init.lua. If you have more than a few of them open, it becomes extremely frustrating to keep track of which is which.

You have to manually distinguish them by hovering over the tab to see the file path, or look at the contents of the file itself.

As an aside, I'm aware that there are certain settings in popular IDEs like VSCode that can help mitigate this issue by naming the tab based on the parent directory. But the fact remains: this is a workaround. Fundamentally, the problem isn't solved, which is why I will be ignoring these settings for the sake of this discussion.

It creates a structural lie

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, FooModule is a directory with a sibling init.luau.
  • In Roblox, FooModule is a Script with a child BarModule.

It's a refactoring nightmare

The 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)

I think this is my biggest gripe with this pattern. As I mentioned before, the original Lua pattern is not inherently bad. It has its use cases, and it can be a good way to organize code in certain scenarios.

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.

On 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.

Init files should only be used to denote entry points for libraries. And even then, they should be used with caution. But when the status quo encourages you to turn your 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.Child syntax.

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.

@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

Regardless of how anybody feels about the init pattern, the fact is that it's too late to change it now. I respect Roblox's initial attempt to create a more sound solution, but the fact that they had to backpedal, add new syntax, and accommodate the language to 3rd-party tool conventions is a testament to just how deeply ingrained it has become.

When walking back Luau, 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 and it's used in some pretty big projects, so these changes still have an impact on a small chunk of the community.

When implementing how Azul syncs from and to Roblox, I wanted to be honest. Each file exists on its own, and if it needs children, you simply make it a sibling folder with the same name.

I also made the tough decision to not support init.luau at all. While Rust's pattern is a bit more verbose, it's also much more intuitive and robust for Roblox's DataModel.

The bottom line

Maybe I come across as a bit harsh in this article, so I do want to make it clear that I don't entirely hate the init pattern. The small context loss from the original Lua pattern is still not ideal, but it's not nearly as illegible as how many modern Roblox codebases look thanks to it.

It's just not the correct solution to display aggressive nesting, which is something that Roblox encourages with its DataModel.

The init.luau pattern was a well-intentioned solution that came from necessity, but it introduces frustration and structural ambiguity we shouldn't accept anymore. It forces the community to adopt a confusing and frustrating way of organizing code.

There are better ways to organize code, especially for a platform like Roblox.