skip to content

Whole-File Read and Write

os.ReadFile and os.WriteFile are one-line conveniences that hide real behaviour: WriteFile truncates an existing file and its perm argument only applies when it creates one.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

What does os.ReadFile give you, and what work does it do that you would otherwise write by hand?

level: juniorimportance: must knowfreq 80%

answer

  1. one call, no file handle in sight
  2. who opened it, and who closes it?
  3. the loop stops somewhere — is that an error?
  4. bytes, not text; convert if you want a string

basics

~20 s

os.ReadFile opens the named file, reads it to the end, closes it, and returns the whole contents as a []byte together with an error. Reaching end of file is not an error, so a successful read returns a nil error.

solid answer

~50 s

`os.ReadFile(name string) ([]byte, error)` is the one-call way to slurp a file. It opens the file, reads until end of file, closes it for you, and hands back the bytes. You never see a `*os.File`, so there is nothing to close and nothing to defer. Crucially, a successful call returns `err == nil`, not `io.EOF` — end of file is the expected stopping point, not a failure, so `if err != nil` means a real problem such as the file being missing or unreadable. You get `[]byte` rather than `string` because that is the file's raw content; `string(data)` copies it if you want text. Internally it stats the open file and uses the reported size as the initial capacity for the slice, then grows as needed, so files whose size is reported as zero still read correctly. Since Go 1.16 this lives in `os`; the old `io/ioutil` version is deprecated.

code

go · 8 lines
go
data, err := os.ReadFile("templates/model.tmpl")
if err != nil {
	// a real failure: missing, unreadable, a directory
	return fmt.Errorf("read template: %w", err)
}

// err is nil here even though the read stopped at end of file.
tmpl := string(data) // explicit, copying conversion to text

go deeper

for a junior

Be ready to say the signature out loud: it takes a path and returns bytes plus an error, and it opens and closes the file itself. Know that a successful read gives a nil error, never io.EOF.

for a middle

Explain the mechanics: the stat-derived capacity hint, the read loop to end of file, and why the result is []byte rather than string. Say what converting to string costs.

for a senior

Show judgment about when whole-file reading is the right shape at all — inputs whose size you control — and be able to state the trade in one sentence rather than reciting the API.

for a principal

Frame it as a default for the codebase: which inputs are allowed to be read whole, who decides that, and how the team keeps a convenient one-liner from becoming an unexamined habit on inputs nobody sized.

## The call ```go func os.ReadFile(name string) ([]byte, error) ``` One argument, two results. Give it a path, get back every byte in the file and an error. It is the shortest correct way to read a whole file in Go, and for a code generator loading a 40-line template it is exactly the right primitive. ## What it does, step by step 1. Opens the file for reading. If that fails — the path does not exist, you lack permission, it is a directory — you get a nil slice and an error. 2. Stats the now-open file to learn its size, and uses that as the **initial capacity** of the byte slice it is about to fill. This is an allocation hint, not a contract: if the file turns out to be larger, the slice grows; if the stat reports zero (as it does for many synthetic files such as those under `/proc` on Linux), the read still works, it just starts from a small default capacity. 3. Reads in a loop until end of file. 4. Closes the file. You never get a handle, so you have nothing to close and no `defer` to remember. 5. Returns the accumulated `[]byte`. ## End of file is not an error This is the single most common misunderstanding. The lower-level `Read` method returns `io.EOF` when there is nothing left, and code that drives a reader by hand must treat that sentinel as "done", not "broken". `os.ReadFile` absorbs that for you: it reads *until* EOF by design, so a fully successful read returns `err == nil`. If you write `if err == io.EOF` after `os.ReadFile`, that branch never fires. A non-nil error from `os.ReadFile` always means something genuinely went wrong. A related consequence: `os.ReadFile` on an empty file succeeds. You get a zero-length (possibly non-nil) slice and a nil error. Zero bytes is a legitimate answer, not an error condition, so a caller that cares about emptiness must check `len(data)` itself. ## Why []byte and not string A file is bytes. Go's `string` is an immutable byte sequence, and converting `[]byte` to `string` normally copies, because the two have different mutability guarantees. Returning `[]byte` lets you: - hand the bytes straight to a decoder or parser that takes `[]byte` without paying for a copy; - mutate them in place if that is what you need; - decide for yourself whether you ever want a `string` at all. If you do want text, `tmpl := string(data)` is the explicit, copying conversion, and it is cheap enough for small files that you should not think about it until a benchmark says otherwise. ## Where the whole-file shape fits Whole-file reading suits inputs whose size you control and understand: config files, templates, fixtures, a manifest, a generated file you just wrote. The trade is simple and worth stating out loud in an interview: the entire file is in memory at once, in a single contiguous slice, for as long as you hold the slice. For a generator reading a directory of small templates that is not merely acceptable, it is the fastest and simplest thing you can do — one open, one close, one allocation sized from the stat, no intermediate buffering machinery. If you benchmark it against reading the same input incrementally, `-benchmem` makes the difference concrete: the whole-file version typically shows one large allocation roughly the size of the file, while an incremental parse shows many small ones. Which is better depends entirely on whether you need the whole thing in memory anyway. A template you are about to pass to a template engine, you do. ## History and naming Before Go 1.16 this function lived in `io/ioutil` as `ioutil.ReadFile`, with the identical signature. Go 1.16 moved the useful parts of `io/ioutil` into `os` and `io`, and `io/ioutil` is deprecated: the functions still exist and still work, but new code should use `os.ReadFile`. Seeing `ioutil.ReadFile` in a review is a reliable sign the code predates 1.16 or was copied from an old answer. ## What an interviewer is checking That you reach for the standard-library one-liner instead of hand-rolling an open/read-loop/close; that you know the returned error semantics (nil on success, no `io.EOF`); that you know the file is already closed; and that you can say in one sentence what you traded away — the whole file is resident in memory.

  • Why does os.ReadFile return []byte instead of string?
    Because a file is bytes, and Go's `string` is immutable. Returning `[]byte` lets you pass the data straight to a decoder that takes bytes, or mutate it, with no copy. If you want text you convert explicitly with `string(data)`, which copies — an explicit cost rather than a hidden one.
  • os.ReadFile stats the file it opened. What does it do with the size?
    It uses the reported size as the initial capacity of the byte slice, purely as an allocation hint. It still reads until end of file and grows the slice if the file is bigger, so files whose stat reports zero bytes — many synthetic files do — are read correctly anyway.
  • What does os.ReadFile return for an empty file?
    A zero-length slice and a nil error. Empty is a valid file, not a failure, so nothing in the error path tells you about it. A caller that treats an empty config as a bug has to check `len(data) == 0` itself.
  • You are making a generator faster without changing its output. What would a -benchmem benchmark tell you about reading templates whole?
    It reports bytes and allocations per operation. A whole-file read usually shows one allocation close to the file's size, sized from the stat hint; an incremental parse shows many smaller ones. For templates you must hold entirely anyway, the single sized allocation is normally the cheaper shape, and the benchmark is how you prove it rather than guess.

saying these in an interview costs you the question

  • Checks for io.EOF after os.ReadFile; that branch never fires
  • Tries to close something os.ReadFile returned
  • Says os.ReadFile returns a string
  • Thinks it reads only one buffer's worth of the file
  • Reaches for ioutil.ReadFile, deprecated since Go 1.16
  • Treats an empty file as an error result
open as a page

What does os.WriteFile do to a file that already exists, and when is its perm argument actually used?

level: middleimportance: must knowfreq 62%

basics

~20 s

os.WriteFile truncates an existing file to zero and writes the new bytes over it, creating the file only when it is missing. The perm argument applies at creation only, is filtered by umask, and never changes an existing file's mode.

open as a page

How do os.CreateTemp and os.MkdirTemp name what they create, and who is responsible for deleting it?

level: middleimportance: should knowfreq 44%

basics

~20 s

Both take a directory and a pattern; a random string replaces the last asterisk in the pattern, or is appended when there is none. An empty directory argument means os.TempDir. Neither cleans up: the caller must remove the file or directory itself.

open as a page

os.WriteFile rewrites a file in place, so how can a concurrent os.ReadFile of it return half a document and a nil error?

level: seniorimportance: should knowfreq 33%

basics

~20 s

os.WriteFile truncates the file to zero before writing, so for a moment it really is empty or holds only a prefix. A reader that opens then reaches a genuine end of file, gets fewer bytes, and sees no error.

open as a page