1. A file as a resource: opened it — close it
When we’re just starting to program, a file feels like “a folder with letters” that you can access at any moment. But for the operating system, a file is a resource that you open, get a “handle” to (a descriptor), and then must close. If you don’t close files, the program will “seem to work” at first, and then suddenly start acting up: it can’t open new files, it doesn’t write data to the end, or it hits weird errors.
In Go, an open file is represented by the type *os.File. This value does not store the entire file in memory (that’s important!); it stores access to the file through the OS. So the main discipline of today’s lecture is: opened a file — close the file, and the best way is to do it immediately via defer.
To make this stick, it helps to keep a mini lifecycle diagram in mind:
flowchart TD
A[Built the file path] --> B[Opened/created the file -> got *os.File]
B --> C["Immediately set defer Close()"]
C --> D["Read or write (Read/Write/WriteString)"]
D --> E[Function ends -> defer closes the file]
2. defer f.Close(): place it after checking err
defer in Go is not “magic”, but a very practical mechanism: it guarantees that the call will happen when the function exits (even if it exits via return in the middle). In the official example about copying files, they show that defer is placed right after a successful os.Open/os.Create, so you don’t forget to close the resource on any execution path.
The key point for beginners: you can’t place defer before checking the error. First you check err, and only if err == nil do you have a valid *os.File that you can close.
The correct template looks like this:
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("items.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer f.Close() // ok: f is definitely not nil and the file is actually open
fmt.Println("file opened") // file opened
}
If you put defer f.Close() before if err != nil, then on error f will be nil, and you’ll get a different problem (most likely a panic when trying to close a nil pointer, or a secondary behavioral error). In other words, you tried to fix one thing and broke another—classic developer “diet”: eating bugs and washing them down with more bugs.
3. File open modes: Open, Create, OpenFile
os.Open: reading an existing file
os.Open(name) is the simplest way to open a file for reading. It does not create the file, does not overwrite, and does not append—it simply opens an existing file. This is a great mode when you want to load data (for example, a task list in a small educational app).
Low-level reading is done via the method Read(p []byte) (n int, err error). It tries to read data into your buffer p and returns how many bytes were actually read. The error io.EOF in this context is not a “catastrophe”, but the normal signal: “we reached the end of the file”.
Mini example: read the first 8 bytes (purely to demonstrate the mechanics):
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("items.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer f.Close()
buf := make([]byte, 8)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
fmt.Println("read:", err)
return
}
fmt.Printf("read %d bytes: %q\n", n, buf[:n]) // read 0 bytes: ""
}
Notice the check: err != nil && err != io.EOF. We’re explicitly saying: “any error is bad, but EOF is normal completion”. This simple habit saves a lot of nerves when you start reading files in chunks.
It’s important not to interpret buf as a whole: use buf[:n]. Otherwise you’ll print extra zero bytes and start thinking that the file is “full of some \x00”.
os.Create: writing with truncation
If os.Open is “read only”, then os.Create(name) is “write from scratch”. It creates the file if it doesn’t exist, and if it does exist, it truncates its contents (truncate). This behavior is useful when you want to re-save the entire file, but dangerous if you accidentally expected to “append”.
So Create is like a “New version of the document” button that doesn’t ask “are you sure?”. Use it intentionally.
Example: overwrite "items.txt" with a single line.
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Create("items.txt")
if err != nil {
fmt.Println("create:", err)
return
}
defer f.Close()
_, err = f.WriteString("buy milk\n")
if err != nil {
fmt.Println("write:", err)
return
}
}
The method WriteString is handy when you’re writing text. It returns (n int, err error)—how many bytes were written. In educational examples we often ignore n, but in “real” code, for partial writes (rare, but possible) it can matter.
os.OpenFile: the universal method and flags
When you see os.Open, os.Create, and then also os.OpenFile, it can feel like: “why couldn’t they leave just one?”. In fact, OpenFile is the universal mechanism. And Open and Create are just convenient “pre-configured” options for the most common cases.
The signature is:
os.OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
Here flag is a bitmask (we’re not diving into bits today; it’s enough to think of it as “you can combine them with |”), and perm is the access mode that is applied when creating the file.
The minimal set of flags we need today:
- os.O_WRONLY — open for write only,
- os.O_CREATE — create the file if it doesn’t exist,
- os.O_EXCL — “create only if it does not exist” (usually together with O_CREATE),
- os.O_APPEND — always write at the end of the file.
To avoid keeping all of this in your head as a mess, here’s a small cheat-sheet table:
| Task | How to open |
|---|---|
| Read an existing file | |
| Overwrite a file from scratch | |
| Append to the end (log, list of lines) | |
| Create “strictly once” (do not overwrite) | |
O_APPEND: append to the end
When you store data as a list of lines (for example, “tasks”, one per line), the natural scenario is to add a new line to the end. That is exactly what O_APPEND does.
Imagine an educational mini app “to-do list”: we want to add a new task to a file without overwriting everything. Here’s a mini function appendLine:
package main
import (
"fmt"
"os"
)
func appendLine(filename string, line string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("open for append %q: %w", filename, err)
}
defer f.Close()
if _, err := f.WriteString(line + "\n"); err != nil {
return fmt.Errorf("append line to %q: %w", filename, err)
}
return nil
}
func main() {
if err := appendLine("items.txt", "walk the dog"); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("ok") // ok
}
There are several useful habits here “in one bottle”: we use OpenFile because we need append mode; we do defer f.Close() right after a successful open; we add context to errors with "%w" so the original cause isn’t lost and can be recognized higher up the stack.
O_CREATE | O_EXCL: create only if the file doesn’t exist
Sometimes you need behavior like “create the file, but only if it doesn’t exist yet”. This is useful for scenarios like “initial initialization”, “lock file”, “create a config template and don’t overwrite the user’s”.
That’s what the combination O_CREATE|O_EXCL is for. The idea: O_CREATE allows creation, and O_EXCL requires the file to not exist. If the file already exists—you’ll get an error.
Example: create "items.txt" only once and write a header:
package main
import (
"fmt"
"os"
)
func createOnce(filename string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return fmt.Errorf("create once %q: %w", filename, err)
}
defer f.Close()
if _, err := f.WriteString("# todo items\n"); err != nil {
return fmt.Errorf("write header: %w", err)
}
return nil
}
func main() {
if err := createOnce("items.txt"); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("created") // created
}
Note: for now, we are not classifying the “file already exists” error as a separate branch (that will be a separate topic about error classes). In this lecture, it’s important to understand the mechanics and the idea of “create strictly once”.
4. Common I/O errors and normal io.EOF
Working with files is an area where errors are not just possible—they’re expected. The file might not exist. The process might not have permissions. The path might point to a directory, not a file. The file might be locked by another process (in some OS scenarios). And the most annoying part: an error might happen not on Open, but on Write (for example, the disk runs out of space).
Psychologically, it helps to accept the fact: “I/O errors aren’t shameful; they’re reality”. The programmer’s job is not to “avoid them forever”, but to carefully check err after operations, add clear context, not forget to close resources, and not confuse normal signals like io.EOF with an actual failure.
By the way, the good news: errors in Go are values; you can wrap them, pass them upward, and analyze them. In the standard library, errors often contain structure and additional fields, not just text—this is one of the reasons Go likes the “errors are values” approach.
The practical takeaway for the current level: never write code that ignores err for file operations. Even if it “seems to work”.
5. Example: a todo list in a file
So that today’s lecture doesn’t remain a set of scattered calls, let’s put together a small (and honest) story. Suppose we have a simplest command “add a task”. We’re not building a full CLI parser, complex formats, JSON, and so on. It’s enough to show that a file can be used as a very simple journal.
We’ll do two steps: first we “initialize” the file if it doesn’t exist via createOnce, then we append tasks via appendLine.
Here’s a compact version (yes, it’s still a single file "main.go": today the file mechanics matter more than package architecture):
package main
import (
"fmt"
"os"
)
func createOnce(filename string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return fmt.Errorf("create once %q: %w", filename, err)
}
defer f.Close()
_, err = f.WriteString("# todo items\n")
if err != nil {
return fmt.Errorf("write header: %w", err)
}
return nil
}
func appendLine(filename string, line string) error {
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("open for append %q: %w", filename, err)
}
defer f.Close()
_, err = f.WriteString(line + "\n")
if err != nil {
return fmt.Errorf("append: %w", err)
}
return nil
}
func main() {
_ = createOnce("items.txt") // if the file already exists, this will be an error; we ignore it for now
if err := appendLine("items.txt", "learn os.OpenFile flags"); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("saved") // saved
}
What matters here “in meaning”: we don’t use os.Create because it would wipe the file every time, while we need a “journal”; we use OpenFile with O_APPEND because it fits the model “each task is a new line” perfectly; we honestly close the file with defer so that even if a write fails we don’t leave the resource open. Yes, we temporarily ignore the createOnce error (this is an educational trick so the file gets created on the first run). In a real application, you would distinguish error causes and behave more carefully, but our goal right now is to understand open modes.
6. Typical mistakes when working with files and defer Close
Mistake #1: forgot to close the file (or closed it “somewhere later”).
The most common beginner problem is to open a file and “close it somewhere later”. That “later” is easy to lose, especially when the code has many return statements on errors. It’s much more reliable to place defer f.Close() right after checking err, as soon as the file is successfully opened. This style isn’t just pretty—it genuinely prevents resource leaks.
Mistake #2: placed defer f.Close() before checking err.
It looks like “well, I want to always close it”, but if Open/Create returned an error, the variable f is not a valid file. Then you risk a panic or a secondary error that masks the real cause. The correct order: first if err != nil, then defer.
Mistake #3: used os.Create expecting “append to the end”.
Create doesn’t just “open for writing”; it creates the file anew and truncates an existing one. So for logs, journals, and lists of lines you need os.OpenFile with O_APPEND. If you don’t remember this difference, one day you can accidentally erase a file with data…and very quickly learn the word “truncate” on an emotional level.
Mistake #4: not checking err for WriteString/Write.
Sometimes it feels like: “the write is simple; what could go wrong?”. And then suddenly the disk runs out of space, a network drive disappears, or the file turns out to be read-only. Write errors are just as real as open errors, so if _, err := f.WriteString(...); err != nil { ... } is not bureaucracy—it’s insurance.
Mistake #5: treating io.EOF as a “fatal error”.
When reading in chunks, EOF means “that’s it, no more data”. It’s not “broken”; it’s the normal completion signal. That’s why the typical check looks like “if err is not nil and not EOF—then it’s a problem”. Otherwise you’ll start “fixing” normal behavior and get confused in the reading logic.
GO TO FULL VERSION