Last Updated: May 22, 2026
The os package is the entry point for working with files in Go. It opens, creates, reads, writes, renames, and deletes them, plus exposes file metadata like size and modification time. Other file packages in the standard library (bufio, io, filepath) all sit on top of the primitives that os provides, so understanding this package first makes the rest of the file I/O story easier to follow.
The simplest way to put bytes on disk is os.Create. It opens the named file for writing, truncates it if it already exists, and creates it with mode 0666 (before the umask is applied) if it doesn't.
A few things to call out. os.Create returns a *os.File and an error. Every example in this lesson follows the same pattern: check the error, then defer f.Close() so the file is released when the function returns. WriteString returns the number of bytes written plus an error, and the byte count is rarely useful (the string length is already known), so we discard it with _.
os.Create always truncates. If products.txt already had a million products in it, that data is gone the moment the call succeeds. This is the right behavior for "make a fresh file" but the wrong behavior for "add to an existing log," and confusing the two is a frequent source of bugs. The next section shows how to append instead.
Under the hood, os.Create is a shortcut for os.OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666). Once you understand OpenFile, the meaning of Create falls out for free.
os.Open opens a file for reading. It's the read-only counterpart of os.Create and fails if the file doesn't exist.
Read fills the buffer with up to len(buf) bytes and returns how many it actually got plus an error. When the file is exhausted, Read returns io.EOF. The loop above keeps reading until that happens. Two details matter. First, n can be non-zero even when err is io.EOF, so check n > 0 before deciding what to do with the buffer. Second, Read is allowed to return fewer bytes than the buffer can hold, even if the file isn't done yet. That's why the loop is necessary instead of a single Read call.
Trying to os.Open a file that doesn't exist returns an error wrapping fs.ErrNotExist. The standard way to check for this is errors.Is:
os.Open is the same as os.OpenFile(name, O_RDONLY, 0). The permission argument is ignored when O_CREATE isn't set, so passing 0 is conventional.
os.OpenFile is the general-purpose function. Both Create and Open are shortcuts for specific flag combinations on top of it. The signature is:
The flag argument is a bitwise OR of access mode and behavior flags. Exactly one access mode must be present, plus zero or more behavior flags.
| Flag | Meaning |
|---|---|
os.O_RDONLY | Open for reading only |
os.O_WRONLY | Open for writing only |
os.O_RDWR | Open for reading and writing |
os.O_CREATE | Create the file if it doesn't exist |
os.O_APPEND | Writes go to the end of the file |
os.O_TRUNC | Truncate to zero length if it exists |
os.O_EXCL | With O_CREATE, fail if the file exists |
The diagram below shows how common flag combinations map to outcomes when the target file may or may not already exist.
The decision tree captures four outcomes you'll actually hit in practice. "Create empty file" is what os.Create does. "Open at offset 0" is what os.Open does. The other two paths (truncate-only and append-only) need OpenFile.
The third argument is os.FileMode, an unsigned integer describing the permission bits. It's only consulted when O_CREATE is in the flags and the file actually has to be created. For a file that already exists, the existing permissions are preserved and perm is ignored.
Permission bits in Go use the same octal notation as Unix. Mode 0644 means the owner can read and write, while the group and others can only read. Mode 0600 means only the owner can read or write. Mode 0755 is the executable equivalent, common for directories and binaries but rarely correct for data files.
| Mode | Owner | Group | Others |
|---|---|---|---|
0600 | read, write | none | none |
0644 | read, write | read | read |
0755 | read, write, execute | read, execute | read, execute |
0666 | read, write | read, write | read, write |
On Windows, the permission bits are simplified: only the owner-write bit matters (read-only versus read-write). On Unix-likes, all nine bits are honored, modified by the process umask.
Here's an append-only example. A web server that logs every order to orders.log wants new entries added to the end, never overwriting the existing log.
Run this program twice, and the second run adds three more lines after the first three. The combination O_WRONLY|O_CREATE|O_APPEND is the idiomatic way to open a log file: write-only access, create it if missing, and force every write to go at the end of the file.
Cost: On most operating systems, writes to a file opened with O_APPEND are guaranteed to be atomic for sizes up to PIPE_BUF (4096 bytes on Linux). That's why log files in multi-process servers usually open with O_APPEND. Without it, two processes writing concurrently can interleave bytes mid-line.
If a file must not be overwritten, combine O_CREATE with O_EXCL. The call fails with fs.ErrExist if the file is already there. This is the safe pattern for a daily inventory snapshot where overwriting yesterday's data would lose information.
O_EXCL only has meaning combined with O_CREATE. By itself it does nothing. The combination is the standard way to create a file without race conditions: the kernel guarantees that "check whether the file exists, then create it" happens as a single atomic step.
Once a file is open, the *os.File value satisfies both io.Reader and io.Writer. That means Read and Write work on byte slices. The interfaces themselves get the full treatment later; for now, focus on what the methods do.
A few observations. The buffer is 16 bytes, so each Read call returns up to 16 bytes. The final call returns 11 because that's all that was left. The first two calls also illustrate that Read doesn't respect "natural" boundaries like newlines: it just hands back whatever bytes were available. If you want line-by-line access, you want bufio.Scanner.
Write is the mirror operation. It takes a []byte and returns the number of bytes written plus an error. A short write is possible in principle but rare for local files; it shows up more often with network connections and pipes. Either way, idiomatic Go ignores neither the byte count nor the error:
WriteString is a convenience that skips the []byte(...) conversion. For string data, prefer it. The conversion isn't free: it copies the string into a new byte slice on the heap.
Cost: []byte("hello") allocates a new slice and copies the bytes. f.WriteString("hello") writes directly from the string's underlying memory. In a hot loop that writes lots of small strings, the difference matters.
For files small enough to fit in memory, two helpers replace the open-loop-close dance with a single call.
os.ReadFile opens the file, reads everything into a byte slice, and closes it. os.WriteFile creates or truncates the file, writes the entire slice, and closes it. Both handle the error and close paths for you.
These two helpers are the right choice for configs, small CSV exports, JSON dumps, and any file you'd be happy to hold entirely in memory. They are the wrong choice for a 10 GB log: ReadFile would try to allocate a 10 GB byte slice. The threshold isn't a fixed number; it's "fits comfortably in the process's available memory."
os.WriteFile is equivalent to os.OpenFile(name, O_WRONLY|O_CREATE|O_TRUNC, perm) followed by a single Write and a Close. It always truncates, so if you want to append to an existing file, WriteFile won't help; use OpenFile with O_APPEND instead.
A note on history. Before Go 1.16, these functions lived in io/ioutil as ioutil.ReadFile and ioutil.WriteFile. They moved to os in 1.16 and the io/ioutil versions became thin wrappers. Use the os versions in new code.
os.Stat returns a FileInfo describing a path without opening it for I/O. The same information is also available from *os.File.Stat() on an already-open file.
FileInfo is an interface with five methods:
| Method | Returns |
|---|---|
Name() string | The base name of the file, no directory component |
Size() int64 | Length in bytes for regular files |
Mode() FileMode | The permission bits and type bits |
ModTime() time.Time | Last modification time |
IsDir() bool | Shortcut for Mode().IsDir() |
Mode() returns a FileMode that prints as a Unix-style permission string. The leading - means "regular file"; d would mean directory, l would mean symbolic link. The mode value also carries the permission bits, which you can extract with info.Mode().Perm().
A common use of Stat is "does this file exist?" Pair it with errors.Is(err, fs.ErrNotExist) to get a robust check:
The function distinguishes three cases: the file exists, the file is missing, and stat failed for some other reason (permission denied, broken symlink, I/O error). A naive _, err := os.Stat(name); return err == nil would treat a permission error as "doesn't exist," which can mask real problems.
There's also a subtle race condition here. By the time the calling code acts on the answer, the file's existence may have changed. This pattern is called TOCTOU (time-of-check to time-of-use), and the right fix is usually to skip the check and just try the operation: os.OpenFile with O_CREATE|O_EXCL is race-free in a way that Stat followed by Create is not.
A *os.File keeps a current offset. Each Read and Write advances it. Seek moves the offset manually. The signature is Seek(offset int64, whence int) (int64, error), and the whence argument is one of three constants:
| Constant | Meaning |
|---|---|
io.SeekStart (0) | Relative to the start of the file |
io.SeekCurrent (1) | Relative to the current offset |
io.SeekEnd (2) | Relative to the end of the file |
Seek is most often used to skip a header, re-read part of a file, or get the file size without Stat (seek to the end, the returned offset is the size). It works on files opened for read, write, or read-write, with one important exception: a file opened with O_APPEND always writes at the end regardless of where the seek pointer is. The operating system enforces this on every write, which is part of what makes O_APPEND safe for concurrent loggers.
Sync forces any buffered data the kernel is holding for the file to be written to durable storage. Without it, a power loss right after Write could lose the data even though Write returned no error. Most application code doesn't need Sync; the operating system flushes periodically and at clean shutdown. Code that has to guarantee durability (a database commit, a checkpoint file, a financial ledger) calls Sync before returning success to the caller.
Cost: Sync triggers a real disk write. On a spinning disk it takes milliseconds, on an SSD it's faster but still costs an order of magnitude more than an unsynced write. Don't call it after every line of a log; batch writes and sync once at the end.
Now for closing. The defer f.Close() pattern is everywhere because Go has no destructors and files are scarce resources (the OS limits how many a process can keep open). Without Close, the file descriptor leaks until the program exits.
For files opened for reading, ignoring the error from Close is conventional and almost always safe. For files opened for writing, ignoring the error is a bug. The reason is that Write may return success while the data is still sitting in a kernel buffer, and the actual disk error doesn't surface until Close flushes that buffer. A failed Close after writes means the file on disk is incomplete or corrupted.
The idiomatic pattern for writes uses a named return so a deferred close can set the error:
This pattern looks ugly the first time you see it, and there's a good reason for that: it has to handle three error sources (the Write itself, the Close, and the case where both fail). The convention is to return the Write error if there was one, otherwise the Close error. That way the caller never gets nil back for a write that didn't actually make it to disk.
os.Remove deletes a single file or empty directory. It returns fs.ErrNotExist if the target wasn't there to begin with. os.RemoveAll deletes a path and all its contents recursively; it's the equivalent of rm -rf, so use it deliberately.
os.Rename moves a file from one path to another. On the same filesystem it's atomic (the rename either happens or doesn't, never a half-moved state). Across filesystems it falls back to a copy-then-delete on most platforms and loses atomicity. This is why the standard pattern for "safely overwrite a config" is to write to a temporary file in the same directory, then Rename it over the target:
A reader that opens products.csv mid-update always sees either the old version or the new version, never a partial write. This pattern is so common it has a name: atomic file update. The same idea underpins how text editors save files, how go install deploys binaries, and how many databases write their on-disk state.
A few mistakes show up over and over in Go file code. They're all simple to spot once you know what to look for.
Forgetting to close the file. The fix is defer f.Close() immediately after the error check on Open/Create. Without it, every iteration of a loop that opens files leaks a descriptor until the process eventually fails with "too many open files."
The early return on the WriteString error skips f.Close(). The defer version handles both the success path and every error path in one line:
Using `os.Create` when you meant to append. os.Create truncates. Opening orders.log with os.Create once a day to "add today's orders" wipes yesterday's log without warning. The right tool is os.OpenFile with O_WRONLY|O_CREATE|O_APPEND.
Ignoring the error from `Write`. f.Write(buf) can return fewer bytes than len(buf), or zero bytes plus an error. Code that writes f.Write(buf) and moves on assumes neither happens. For local files it almost never does, but the habit of checking propagates correctly when the same code later runs over a network connection where short writes are routine.
Ignoring the error from `Close` on a writer. Covered earlier. The corruption window is narrow but real.
Closing a file twice. Calling Close on a file that's already closed returns an error wrapping fs.ErrClosed. This happens when both an explicit f.Close() and a deferred defer f.Close() run, or when a helper function takes ownership of the file and closes it. Pick one place to do the close, and trust it.
Hardcoding path separators. Writing "data/orders.log" works on Unix and Windows in modern Go (the OS accepts forward slashes), but constructing paths with + "/" + is fragile. The filepath.Join function handles it correctly.