February 16, 2021·7 min read·engineeringgoembedmodulesfilesystems

Go 1.16 makes files part of the build

Embedded assets and a read-only filesystem interface give Go programs a common way to consume packaged files. Module defaults also separate ordinary builds from dependency changes.

A program that needs a template also needs a decision about where that template lives. Keeping it beside the executable introduces a deployment dependency. Generating source code containing its bytes moves that dependency into a separate build step. Go 1.16 makes compile-time file inclusion part of the standard toolchain, and it gives file consumers an interface that works across several storage choices.

The February 16 release announcement introduces the embed package alongside module mode becoming the default. These changes meet at the build. Source code can now declare supporting files that belong in the executable, while ordinary build commands stop silently updating module requirements and checksums. The result is a more explicit account of what a build consumes.

I like Go most when the implementation remains understandable without a collection of local conventions. Declaring an asset beside the variable that receives it is consistent with that preference. A separate generator can be appropriate, but a standard mechanism removes one reason to maintain one.

The asset declaration is a build input

The Go 1.16 embed implementation and package documentation describe three supported destinations for embedded data. A declaration can initialize a string, a byte slice, or an embed.FS value. The toolchain reads the matching files during compilation and includes their contents in the program.

For a single text file, a package-level string is enough. This example assumes that message.txt exists in the package directory when the program builds. Its contents become the initial value of message.

package greeting

import _ "embed"

//go:embed message.txt
var message string

func Message() string {
    return message
}

The blank import is required even though the declaration does not name an exported type from embed. The directive belongs immediately above a single variable declaration, with only blank lines and line comments allowed between them. It works at package scope. A function-local variable cannot use it to load a file dynamically.

That timing determines the operational behavior. Changing message.txt beside an already built executable does not change the embedded string. A new build is necessary. This is useful for files that should version with code, such as an application-owned template. It is a different requirement from configuration that an operator needs to change independently of the executable.

The declaration also makes missing assets a build problem. A pattern must match at least one file or nonempty directory after the documented exclusions. Invalid patterns or invalid matches cause the build to fail. A program can therefore avoid a class of startup failures in which deployment copied the executable but omitted a required supporting file.

Directory matching has concrete rules

An embed.FS can hold a tree containing multiple files. Patterns are relative to the package directory, and they use forward slashes on every operating system. They cannot begin or end with a slash or contain empty path elements. Parent traversal is unavailable, so a directive cannot reach into a sibling directory through a sequence of parent components.

The documentation also excludes matches outside the package's module and symbolic links. An asset declaration describes files that belong to the package's build inputs. It is not a general way to traverse the build machine. That restriction gives a reviewer a smaller set of locations to inspect when working out what will enter a binary.

Directory patterns require attention because superficially similar forms include different files. Naming a directory embeds its subtree recursively but excludes names beginning with a dot or underscore. A wildcard can match such a name directly. The documentation illustrates this with an image directory whose wildcard matches a dot-prefixed temporary file that a directory-only pattern would omit.

This is a reason to make the pattern express the intended assets precisely. A broad pattern changes the executable when matching files appear in the package. Generated files and editor output therefore need to remain outside the chosen asset set. The mechanism makes the file list part of the build, but the package author still decides what that list should mean.

For a string or byte slice, the pattern must identify one file. The filesystem form handles multiple files and directories. Those choices have different consumers, so choosing the destination type is part of the package's API design rather than a stylistic detail.

A filesystem interface connects the pieces

The release notes introduce io/fs as an abstraction for read-only trees of files. embed.FS implements that interface. The same notes identify zip.Reader and os.DirFS as other implementations. A consumer can accept an fs.FS without making the source of its files part of its own implementation.

This is the larger architectural change behind embedding. A template loader should not need a separate parsing implementation for embedded files and ordinary directory files. The template packages now provide ParseFS functions and methods, and net/http provides http.FS to adapt the new interface to its existing filesystem API.

Consider a package that parses its templates from an fs.FS supplied by its caller. An executable can supply embedded templates, while a development tool can supply a directory-backed implementation. The package's file-reading contract stays the same. Differences in where the data lives remain with the caller that selects the implementation.

Testing also has a standard entry point. The new testing/fstest package includes MapFS for consumers that need an in-memory filesystem and TestFS for checking filesystem implementations. Those are distinct jobs. A fake file tree makes a parser test independent of the host directory layout, while an implementation check examines whether a filesystem follows the expected contract.

I find this more useful than embedding alone. A standard interface gives a package a reason to expose only the file operations it consumes. The deployment decision can stay outside the parsing logic, which makes both the dependency and the code easier to read.

Module mode no longer depends on finding go.mod

Go 1.16 enables module-aware mode by default even when the current directory and its parents do not contain a go.mod file. The release notes describe the change as GO111MODULE defaulting to on. Setting it to auto restores the previous selection behavior. The default changes which dependency model the command uses, rather than making a missing module declaration appear automatically.

Build and test commands also change their treatment of dependency files. They now report an error when a requirement or checksum must be added or updated. Previously, an ordinary build could make those changes as part of its work. Explicit dependency commands such as go mod tidy or go get can perform the adjustment.

This separation gives a failed build a more specific interpretation. It may indicate that dependency metadata needs an intentional update. Running the build again is not supposed to repair that metadata silently. A developer can make the update separately and review its diff as a dependency change.

For automation, this reduces ambiguity about whether a command that appears to compile code also rewrites the module's inputs. A repository that already treats dependency updates as reviewed changes benefits from the toolchain adopting that distinction. The behavior does not guarantee that every input is reproducible, but it makes one source of implicit mutation explicit.

Installing a tool has its own dependency context

The release adds version suffixes to go install arguments. A command naming a package and a version builds and installs in module-aware mode while ignoring a go.mod in the current directory or its parents. The release notes describe this as a way to install executables without affecting the main module's dependencies.

That resolves a practical ambiguity in tool installation. The dependencies required to build a command are not necessarily dependencies of the application in the current directory. Selecting the command's version at installation time gives the operation its own context. It avoids treating an application module as an incidental place to record a tool installation.

Go install is now the recommended installation command in module mode. The release notes recommend go get with its -d flag for dependency adjustments and deprecate using go get to build and install packages. These recommendations describe the behavior of this release, including the distinction between modifying dependencies and producing an installed executable.

Module authors also gain retract directives for versions that should no longer be selected by consumers. The notes mention severe problems and unintentionally published versions as reasons for retraction. This supplies metadata about an existing publication, so a mistaken version can be identified through the module's own declarations.

A release centered on implementation

Go 1.16 has no language changes, according to its release notes. Its new capabilities arrive through the toolchain and libraries. The new macOS ARM64 port is another substantial example, with support for cgo and the race detector. Existing Go syntax can therefore describe programs that build for another supported platform without a language revision.

The release retains the Go 1 compatibility promise, and existing io/ioutil functions continue to work despite their deprecation. Their replacements now live in io and os. Adoption can therefore follow the part of a program that benefits from the newer interfaces instead of requiring an immediate repository-wide rewrite.

For my purposes, the significant improvement is that package files and module metadata have clearer roles in producing an executable. Embedded assets travel with the build that captured them. File consumers can operate through fs.FS, and dependency adjustments have explicit commands. A package can adopt those changes where it owns stable assets or needs interchangeable file sources.