AlgoMaster Logo

io & os (replacing ioutil)

Medium Priority25 min readUpdated June 6, 2026

The io/ioutil package was deprecated in Go 1.16. Its contents weren't deleted, they were redistributed: stream helpers moved to io, and filesystem helpers moved to os. New code should call the new names, and existing code is easy to migrate because every old function maps to exactly one new function. This lesson walks the full migration map with side-by-side examples, explains the one subtle behavior change (os.ReadDir returns fs.DirEntry, not os.FileInfo), and shows how to find and rewrite ioutil calls automatically.

Why ioutil Was Deprecated

The io/ioutil package was added in Go 1.0 as a small grab bag of "miscellaneous I/O utilities." Over time, the bag grew to hold eight helpers covering three unrelated jobs: reading and writing whole files, reading entire streams into memory, and creating temporary files and directories. The Go team decided that mixing those jobs in one package was a mistake.

The problems were concrete. A reader scanning import lines couldn't tell whether a package touched the filesystem or just worked on streams, because io/ioutil did both. New file-related helpers had to choose between landing in os (where the rest of the file API lived) or ioutil (where the existing read-whole-file helpers lived), and the split kept getting fuzzier. The name ioutil itself signaled "leftover bin," which is the wrong message for a package that holds heavily-used helpers like ReadFile and ReadAll.

Go 1.16, released in February 2021, fixed this by moving each function to the package it belonged in. Stream helpers (ReadAll, NopCloser, Discard) went to io, where the io.Reader and io.Writer interfaces already lived. Filesystem helpers (ReadFile, WriteFile, ReadDir, TempFile, TempDir) went to os, where os.Open and os.Create already lived. The old names in io/ioutil stayed as thin wrappers that forward to the new functions, so existing code keeps compiling without changes.

The wrappers are not going away. The Go 1 compatibility promise covers them, and tens of thousands of existing programs import io/ioutil. What did change is that the package is frozen, meaning no new functions will ever be added to it, every function has a Deprecated: doc comment, and tools like staticcheck and gopls warn whenever you import it. New code should call the new names directly.

One detail worth pinning down before we walk the map. os.ReadDir is not a pure rename of ioutil.ReadDir. The return type changed from []os.FileInfo to []fs.DirEntry, and that change has performance consequences we'll cover in its own section. Every other migration in this lesson is a straight one-for-one rename with no behavior change.

The Migration Map

Here is the full list of functions in io/ioutil, where each one moved, and notes on any behavior change. This is the table to keep open while migrating a codebase.

Old (io/ioutil)NewBehavior change?Notes
ioutil.ReadAll(r)io.ReadAll(r)NoneReads the entire stream into a []byte.
ioutil.ReadFile(name)os.ReadFile(name)NoneReads the entire file into a []byte.
ioutil.WriteFile(name, data, perm)os.WriteFile(name, data, perm)NoneWrites the data to the file, creating or truncating it.
ioutil.ReadDir(name)os.ReadDir(name)YesReturns []fs.DirEntry instead of []os.FileInfo. Cheaper because stat is deferred.
ioutil.TempFile(dir, pattern)os.CreateTemp(dir, pattern)NoneSame arguments, same semantics, only the name changed.
ioutil.TempDir(dir, pattern)os.MkdirTemp(dir, pattern)NoneSame arguments, same semantics, only the name changed.
ioutil.NopCloser(r)io.NopCloser(r)NoneWraps an io.Reader so it satisfies io.ReadCloser.
ioutil.Discardio.DiscardNoneA Writer on which all writes succeed without doing anything.

Eight rows, two new packages. Of these, only os.ReadDir differs in any way that matters at the call site. The diagram below shows the redistribution visually.

The left side is the old "leftover bin" with eight unrelated helpers. The right side splits them by what they actually do: anything that operates on an io.Reader or io.Writer lives in io, and anything that touches a named path on disk lives in os. With that split in mind, you can usually guess the new name without checking.

Reading a Whole File

ioutil.ReadFile was probably the single most-used function in the old package. A product catalog might be stored as a JSON file on disk, and the service reads it on startup. Here is the old version:

The new version is a pure rename. The signature, return type, and error semantics are identical:

The only change is the import (os instead of io/ioutil) and the call site (os.ReadFile instead of ioutil.ReadFile). The function still reads the entire file into memory in a single call, returns []byte, and handles the file open and close for you.

A reasonable companion call is os.ReadFile plus json.Unmarshal to load a small catalog struct. Here's the same product catalog read and parsed:

The structure of the program is identical to the pre-1.16 version, only the package name on the read call changed.

Writing a Whole File

ioutil.WriteFile and os.WriteFile have the same signature: (name string, data []byte, perm os.FileMode) error. The function creates the file if it doesn't exist, truncates it if it does, writes the data, and closes the file. Consider a service that writes an order summary to disk after a customer checks out.

Same code with the new name:

Both versions create order-4827.txt with mode 0644 (owner read/write, group and others read-only). If the file already existed, both truncate it before writing. There is no append mode in either version; for that you'd open the file directly with os.OpenFile and the O_APPEND flag.

One thing the rename didn't fix: the perm argument is only used when the file is created. If the file already exists, os.WriteFile writes to it but does not change its permissions. The same was true of ioutil.WriteFile. Code that expects "write this file with mode 0600" to enforce the mode on every call will be surprised here. If you need that guarantee, call os.Chmod after the write.

Listing a Directory (the One That Actually Changed)

This is the migration that's not a pure rename. ioutil.ReadDir returned []os.FileInfo, where each entry already had Size(), Mode(), ModTime(), and IsDir() available. os.ReadDir returns []fs.DirEntry, which has a smaller interface: Name(), IsDir(), Type(), and an Info() method that returns the full fs.FileInfo on demand.

The reason for the change is performance. On most operating systems, a directory listing system call (readdir on Linux, FindFirstFileEx on Windows) returns the name and file type of each entry as part of the listing itself. Getting the full stat data (size, modification time, ownership) requires a separate stat call per file. The old ioutil.ReadDir always made those stat calls, even when the caller only wanted the names. The new os.ReadDir skips them by default, and the caller asks for stat data only on the entries that actually need it.

Here's the old version listing the orders/ directory:

The new version, working only with names and the cheap IsDir() check:

No stat calls happened in the second version. For a directory with 100,000 entries, that's 100,000 system calls saved when the caller only wants names. This is the kind of "free speedup" that motivated the API change.

When you actually need the size or modification time, call Info() on the entries you care about. The call returns an fs.FileInfo which has the same methods as the old os.FileInfo:

This pattern lets you pay for stat data only on the entries that need it. A common case: list the directory, filter by name pattern, then stat only the matches.

The Type() method on fs.DirEntry returns a fs.FileMode containing only the type bits (regular file, directory, symlink, etc.), without the permission bits. It's the cheap way to tell directories from files when IsDir() alone isn't enough (for example, when you want to skip symlinks).

If you have a large body of code that relied on the old []os.FileInfo return type, the standard library includes a helper for the conversion: fs.FileInfoToDirEntry goes one way, and looping with entry.Info() goes the other. Most codebases find that updating the call sites to use fs.DirEntry directly is easier than wrapping the new return value to look like the old one.

Reading a Whole Stream

ioutil.ReadAll and io.ReadAll both consume an io.Reader until EOF and return the contents as a []byte. The classic use case is reading an HTTP response body, but it applies to any reader: a network connection, a bytes.Reader, a gzip decoder, the standard input.

The new version replaces ioutil.ReadAll with io.ReadAll. Nothing else changes:

io.ReadAll lives in the io package because it works on the abstract io.Reader interface. It doesn't know or care whether the reader is a file, a socket, or a buffer in memory. That's why moving it to io makes sense: the function depends on io.Reader, not on any filesystem concept.

Temporary Files

A common upload workflow looks like this: receive a stream from a client, copy it to a temporary file on disk, run validation or processing on the file, then move it into permanent storage or delete it. ioutil.TempFile and os.CreateTemp both create a uniquely-named file in the target directory, open it for read-write, and return the open *os.File.

The two signatures and behaviors are identical. Only the name changed.

Same code with os.CreateTemp:

Both versions accept an empty dir argument, which means "use the default temp directory" (the value of os.TempDir(), which respects $TMPDIR on Unix and the equivalent on Windows). The pattern may contain a single *, which gets replaced with a random suffix; if there's no *, the random suffix is appended.

The new name is arguably clearer. TempFile made it sound like the file was somehow magical and would clean itself up. CreateTemp makes it clear that you created a real file and are responsible for removing it. A common pattern is defer os.Remove(tmp.Name()) after the defer tmp.Close().

A small detail that's the same in both versions: the file is created with permission 0600 (owner read-write only). You can't pass a perm argument. If you need a more permissive mode (rare for a temp file), call os.Chmod after creation.

The companion function for temp directories also got renamed:

Same arguments, same return value, same default permissions (0700 for the directory). The new name lines up with os.Mkdir and os.MkdirAll, which is the naming convention for directory creation in the os package.

NopCloser and Discard

The last two helpers are small but show up surprisingly often.

io.NopCloser wraps an io.Reader so it also satisfies io.ReadCloser. The added Close method is a no-op (it returns nil without doing anything). The use case: a function that requires an io.ReadCloser (often an HTTP request body), but you only have a bytes.Reader or a strings.Reader in hand.

io.Discard is a Writer that throws away everything written to it. It's useful when you need to drain a stream without caring about the contents, for example to read past unwanted log noise or to ensure an HTTP response body is fully consumed before being closed (so the underlying TCP connection can be reused).

Both NopCloser and Discard are zero-allocation wrappers around their arguments. Moving them to io was a pure naming change with no runtime impact.

Finding ioutil in a Codebase

Before migrating, find every call. Three tools work well, in increasing order of sophistication.

The simplest is grep or git grep. Search for the import path and the package selector:

This finds the calls but won't tell you which new function to use, and it can miss aliased imports (import iou "io/ioutil").

go vet does not warn on ioutil usage by itself, but a single import of staticcheck with rule SA1019 ("using a deprecated function, variable, constant or field") flags every call:

Each message includes the new function name, so a one-line sed rewrite usually works. We'll cover that in the next section.

gopls, the Go language server that powers go-to-definition and friends in editors, also surfaces these warnings inline. In VS Code, GoLand, Neovim with gopls, or any editor with LSP support, every ioutil.X call shows a yellow underline with a hover message identical to the staticcheck text. Gopls additionally offers a code action ("Replace ioutil.X with os.X") that performs the rewrite for that one call. Some editors expose this as a quick-fix lightbulb.

For continuous-integration coverage, add staticcheck to your CI pipeline and let the build fail on SA1019. New code can't accidentally introduce ioutil calls because the linter rejects them. Most large Go codebases at this point have either zero ioutil imports or a very small allowlist of legacy files.

Automating the Migration

Once you've identified the calls, the rewrite is mechanical. Three approaches cover almost every case.

Approach 1: gopls code actions. Open each file in an editor with gopls support and apply the suggested code action on each warning. Slow for a large codebase, but accurate, and it updates the imports correctly.

Approach 2: `gofmt -r` rewrite rules. gofmt ships with a -r flag that applies AST-level rewrite rules. The syntax is pattern -> replacement, and it understands Go's syntax (so it won't accidentally rewrite a string literal that happens to contain ioutil.ReadFile).

After running these, the ioutil. selectors are gone, but the import "io/ioutil" lines remain (now unused) and the new "io" or "os" imports may be missing. Run goimports -w ./... to fix both:

goimports removes unused imports and adds missing ones. The combination of gofmt -r (rewrite the calls) plus goimports -w (fix the imports) handles every case in the migration table cleanly, except for os.ReadDir callers that use info.Size() on the loop variable.

Approach 3: `sed` for the truly simple cases. When you're sure the codebase doesn't use aliased imports and doesn't have ioutil inside string literals or comments, plain sed works. The downside is that sed is text-based and doesn't understand Go syntax, so a stray // ioutil.ReadFile in a comment also gets rewritten.

This is fast and good enough for most personal projects. For a shared codebase, the gofmt -r approach is safer because it respects Go syntax.

The os.ReadDir rewrite is intentionally not in the sed list. If any caller of ioutil.ReadDir uses info.Size(), info.ModTime(), or info.Mode() directly on the loop variable, a mechanical rewrite to os.ReadDir will break the build because fs.DirEntry doesn't have those methods. Review those call sites by hand and convert them to use entry.Info().

Compatibility and What Stays

The io/ioutil package isn't going away. Every function in it is preserved under the Go 1 compatibility promise, which means a program written against Go 1.0 still compiles and runs on the current Go release. The functions are now thin wrappers around the new ones. For example, the source of ioutil.ReadFile in recent Go releases is essentially:

That's it. Calling ioutil.ReadFile is exactly equivalent to calling os.ReadFile, with one extra function call in between. No correctness difference, no measurable performance difference.

What changed is the package status: io/ioutil is frozen. The Go team will not add new functions to it, will not change its API, and will not fix bugs that depend on observable behavior of the old signatures. Every function carries a Deprecated: doc comment pointing at the replacement. The package shows up at the bottom of pkg.go.dev searches because it's flagged as deprecated.

The practical implications for your code:

QuestionAnswer
Will ioutil.ReadFile work in Go 1.30?Yes, with no behavior change.
Will staticcheck warn on it?Yes, since the deprecation was added in Go 1.16.
Should new code import io/ioutil?No. Use io and os directly.
Is there a performance difference?No. The wrappers are inlined or near-inlined.
Does my old library need to migrate?Only if you want to silence the linter or modernize the code.

For new code, just use the new names. For old code, migrate when it's convenient (touching the file for other reasons, doing a planned cleanup pass, or running into a staticcheck failure in CI). There's no urgency, and a half-migrated codebase compiles fine.

One final note about os.ReadDir. Because the return type changed, this is the one migration that may require thought rather than mechanical replacement. If your code path treats directory entries as opaque names (the common case), the new API is a strict speedup. If your code path needs full stat data on every entry, the new API is no faster, but you can still write the call the new way: entries, _ := os.ReadDir(dir); for _, e := range entries { info, _ := e.Info(); ... }. The behavior matches the old ioutil.ReadDir, just with explicit Info() calls.

Quiz

ioutil Alternatives Quiz

10 quizzes