1. Why you need a data directory
When you write a program, you really want to think that files “just exist”. But the filesystem works like the real world: if you try to put a box on a shelf and there is no cabinet, the box nicely falls onto the floor (in Go, this looks like an error). That is why almost any program that stores data on disk should first make sure the required directory exists.
In the learning app, we will assume we have a small CLI task manager (let’s call it todo) that stores data in a file inside the data/todo directory. At exactly this point, many beginners first run into the "no such file or directory" error and ask a philosophical question: “But I provided the path! Why isn’t that enough?”
A path is just an address. An address does not build a house.
Mini example: “the folder doesn’t exist” as a classic
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Create("data/todo/items.txt")
if err != nil {
fmt.Println("create:", err) // create: open data/todo/items.txt: no such file or directory
return
}
defer f.Close()
}
The error itself is normal: the OS honestly says “I can’t create a file in a directory that doesn’t exist”.
2. os.MkdirAll: what it does and why you need it
In programming, there is a useful kind of laziness: not “I won’t do anything”, but “I won’t do the same thing by hand a hundred times”. Creating directories is exactly that. You can create directories one by one, check “does the parent exist”, then “does the parent’s parent exist”, and somewhere around the third level you will start staring out the window and remembering why you chose software development at all.
os.MkdirAll(path, perm) solves the problem radically: it creates the entire directory chain if something is missing. And if the directories already exist, that is not a disaster and not a reason to panic: it is a normal situation.
It is important to understand that MkdirAll works specifically for directories. It does not create a file, it does not check that the contents are in the “right data format”. It only guarantees: “the directory exists”.
Mini example: preparing a directory for data
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
dir := filepath.Join("data", "todo")
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Println("mkdirall:", err)
return
}
fmt.Println("dir ready:", dir) // dir ready: data/todo
}
Note: we use filepath.Join to avoid manually concatenating strings. And then MkdirAll so we don’t reinvent the wheel with square wheels.
3. Permissions and os.FileMode
Permissions are a topic where a beginner usually says: “I got it… I think… no, I didn’t”. That is normal. Let’s make this as down-to-earth as possible.
In Unix-like systems (Linux, macOS), permissions for files and directories are usually set for three groups: owner, group, and others. Each group gets a set of permissions: read, write, execute. For directories, “execute” means the right to enter (yes, it sounds weird, but that is how it historically evolved).
In Go, permissions are represented by the os.FileMode type. Often you write numbers like 0644 and 0755. And the main surprise: these are octal numbers. The leading zero is not “for looks”; it tells the compiler: “interpret this in base-8”.
A small table: what common modes usually mean
| Mode | For | What it roughly means in practice |
|---|---|---|
|
directory | owner can do everything; others can read/enter |
|
directory | only the owner can read/enter |
|
file | owner can read/write; others can read |
|
file | only the owner can read/write |
In this course, we do not need every nuance; we need a stable minimum: choose permissions deliberately and do not do “just in case 0777”.
Mini example: printing a mode as an octal number
package main
import (
"fmt"
"os"
)
func main() {
var dm os.FileMode = 0755
var fm os.FileMode = 0644
fmt.Printf("dir=%#o file=%#o\n", dm, fm) // dir=0755 file=0644
}
The %#o format is convenient because it prints the number in octal and preserves the “look” of 0755, rather than just 493 (which is also mathematically correct, but annoys people).
4. Safe defaults and modes
There is a special kind of programmer magic: “I’ll set the maximum permissions so it definitely works”. It will indeed work… but you are opening your data to everyone around you. It is like storing your apartment keys under the doormat and then being surprised that someone is eating your pizza from the fridge.
In learning tasks, you might get away with it. In real life, it is a source of problems: accidental data leaks, unexpected access by another user, weird CI bugs, and a sudden “why did a file appear on my server that I didn’t create”.
As healthy defaults, we will follow a simple rule.
- If the data is normal and not secret, use directory 0755, file 0644.
- If the data is private (tokens, keys, personal notes, anything sensitive), use directory 0700, file 0600.
And even if you are currently writing a learning todo, it is useful to build a habit: permissions are part of your application’s contract.
Mini example: “public” and “private” directories
package main
import (
"fmt"
"os"
)
func main() {
if err := os.MkdirAll("data/public", 0755); err != nil {
fmt.Println("mkdir public:", err)
return
}
if err := os.MkdirAll("data/private", 0700); err != nil {
fmt.Println("mkdir private:", err)
return
}
}
Yes, it is two lines. But meaning-wise, it is “we decided which data is visible to whom”.
Directory ≠ file: about the execute bit
Beginners often think: “Well, file 0644 is fine. So the directory should also be 0644”. And here is the trap: a directory is not a file.
A file is read and written. A directory is a container. To “use a directory”, you need permission to enter it and to view/create entries. That is why directories almost always use modes with “execute”, such as 0755 or 0700. If you set a directory to 0644, you will get strange effects: the directory may seem “visible”, but you cannot “enter it”.
It is like a situation where the building’s front door is transparent (you can see inside), but the handle has been removed (you can’t get in). Architecturally creative, but hard to live with.
Mini example: the right “set of modes” as constants
package main
import "os"
const (
dataDirMode os.FileMode = 0755
privateMode os.FileMode = 0700
dataFileMode os.FileMode = 0644
)
We are simply fixing a “project standard”. This makes the code more readable: you do not have to guess why 0755 is here and 0700 is there.
5. Protocol: directory first, then file
As code grows, it is important that it remains predictable. So we will introduce a small habit in our app: any operation with a data file starts by preparing the directory.
This is not “bureaucracy”. It is a way to make behavior stable: you can run the program on a clean machine, in CI, in a new project folder, and it will prepare the structure on its own.
It is convenient to think like this:
built the path → ensured the directory exists → opened/created the file
You can picture it as a mini diagram:
flowchart TD
A[Have a path to the data file] --> B[Compute the file's directory]
B --> C{Does the directory exist?}
C -->|yes| D[Open/create the file]
C -->|no| E["os.MkdirAll(dir, mode)"]
E --> D
Main idea: we do not rely on the environment. We create the minimal required conditions ourselves.
6. Implementation: ensureDataDir and errors
Now we will write a few small functions. This is an important style: instead of smearing MkdirAll all over main, we create an “entry threshold” — one function responsible for preparing the data directory.
And here we again use a Go habit: errors are values; it is convenient to return them upward and add context along the way. That is why wrapping (fmt.Errorf("...: %w", err)) is so valuable: it makes an error clearer without destroying its cause.
A function that knows where the data lives
package main
import "path/filepath"
func dataDir() string {
return filepath.Join("data", "todo")
}
It is a small function, but it matters: if later you decide to store data elsewhere, you change one place instead of twenty.
ensureDataDir: create the directory if needed
package main
import (
"fmt"
"os"
)
func ensureDataDir(dir string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("prepare data dir %q: %w", dir, err)
}
return nil
}
Note three things.
- First, the 0755 mode here is a deliberate default for “normal data”.
- Second, we return the error instead of printing it inside. This makes the code reusable.
- Third, we add context ("prepare data dir ..."), but preserve the cause via %w.
Build the path to the tasks file
package main
import "path/filepath"
func itemsFilePath() string {
return filepath.Join(dataDir(), "items.txt")
}
Same idea here: the path is built via filepath.Join, without manual concatenation.
Use it in main: directory first, then file
package main
import (
"fmt"
"os"
)
func main() {
if err := ensureDataDir(dataDir()); err != nil {
fmt.Println("error:", err)
return
}
f, err := os.OpenFile(itemsFilePath(), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
fmt.Println("openfile:", err)
return
}
defer f.Close()
_, _ = f.WriteString("buy milk\n")
}
Here we can already see the complete protocol: the directory is ready — so the file can be created/opened. We opened the file in append mode (O_APPEND), and chose 0644 as a safe default for a regular text file.
7. Common mistakes when working with directories and permissions
Error #1: creating a file without creating the directory.
This is the most common case. The code looks logical, but the OS is not required to create directories “by guessing”. The fix is simple: before any Create/OpenFile/WriteFile, make sure the directory is prepared via os.MkdirAll.
Error #2: writing 755 instead of 0755 and getting “magic” numbers.
Without the leading zero, the number becomes decimal. As a result, you set different permissions than you think. This is one of those mistakes that is especially nasty because the code compiles and “almost works”, and then starts behaving strangely on someone else’s machine.
Error #3: setting directories to 0644 and being surprised that you “can’t enter”.
A directory requires the “enter” permission (execute). That is why directories typically use 0755 or 0700. If you mix it up, you will get mysterious permission errors even as the owner.
Error #4: “fixing” everything with 0777 because “this definitely won’t be permission denied”.
That can indeed remove one error, but it creates another: you make the data accessible to everyone. Even for a learning app, it is useful to practice discipline and choose the minimally sufficient permissions.
Error #5: printing an error without context and then not understanding where it happened.
If you return just err, then in a large program the log will look like “permission denied” without indicating what exactly you were doing and with which path. Wrapping with fmt.Errorf("...: %w", err) helps make errors engineer-friendly while keeping the root cause available for analysis.
GO TO FULL VERSION