Go 1.16: embed, io/fs, and What Actually Changed
Go 1.16 shipped in February and I've been running it in production since. There are a few changes I care about deeply and a lot of changelog entries I don't. This post covers the ones I actually use: //go:embed, the io/fs interface, and the module/tooling changes that quietly made my life better.
//go:embed: No More go-bindata
Before 1.16, embedding static files in a Go binary required a code generation step. The most common tool was go-bindata, which would read your files and spit out a .go file with byte slices and accessor functions. It worked. It was also annoying: you had to run the generator, commit the generated output, remember to re-run it when files changed, and the generated code was enormous and ugly.
//go:embed is the language-level solution. It's a compiler directive that tells the Go toolchain to embed files directly into the binary at build time.
package main
import (
_ "embed"
"fmt"
)
//go:embed templates/welcome.html
var welcomeTemplate string
func main() {
fmt.Println(welcomeTemplate)
}
That's it. No code generation step. No committed generated files. The file is embedded at compile time, and welcomeTemplate contains the full file contents as a string.
For embedding directories or multiple files, use embed.FS:
package main
import (
"embed"
"html/template"
"net/http"
)
//go:embed templates
var templateFiles embed.FS
func main() {
tmpl := template.Must(
template.New("").ParseFS(templateFiles, "templates/*.html"),
)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", nil)
})
http.ListenAndServe(":8080", nil)
}
I use this for HTML templates, SQL migration files, and default config files. The binary ships with everything it needs. No runtime file path assumptions. No deployment steps to copy assets alongside the binary.
One gotcha: //go:embed does not embed files beginning with . or _, and does not follow symlinks, by default. Use all: prefix to include hidden files: //go:embed all:configs.
io/fs: The Interface That Ties It Together
embed.FS satisfies io/fs.FS, which is the more interesting part. io/fs.FS is a simple read-only filesystem interface:
type FS interface {
Open(name string) (File, error)
}
That's the minimal interface. There are additional optional interfaces: ReadFileFS, ReadDirFS, GlobFS, StatFS. The standard library checks for these with type assertions and uses them when available.
Why does this matter? Because now a lot of things that used to require an actual os.File can accept an fs.FS instead. This makes testing dramatically easier.
// Before: function tied to the real filesystem
func loadConfigs(dir string) ([]Config, error) {
entries, err := os.ReadDir(dir)
// ...
}
// After: function accepts any fs.FS
func loadConfigs(fsys fs.FS, dir string) ([]Config, error) {
entries, err := fs.ReadDir(fsys, dir)
// ...
}
In tests, pass an embed.FS or construct a test filesystem with fstest.MapFS:
func TestLoadConfigs(t *testing.T) {
fsys := fstest.MapFS{
"configs/prod.yaml": &fstest.MapFile{
Data: []byte("environment: production\n"),
},
"configs/dev.yaml": &fstest.MapFile{
Data: []byte("environment: development\n"),
},
}
configs, err := loadConfigs(fsys, "configs")
if err != nil {
t.Fatal(err)
}
if len(configs) != 2 {
t.Fatalf("expected 2 configs, got %d", len(configs))
}
}
No temp directories, no cleanup, no timing issues. The test filesystem lives in memory and disappears when the test ends.
os.DirFS completes the picture — it wraps a real directory as an fs.FS:
configs, err := loadConfigs(os.DirFS("/etc/myapp"), "configs")
Your production code uses os.DirFS, your tests use fstest.MapFS or embed.FS, and your function signature accepts fs.FS. This is the pattern I've retrofitted into several services since 1.16 shipped.
Serving Static Files
The combination of embed.FS + fs.FS + net/http is clean:
//go:embed static
var staticFiles embed.FS
// Serve the embedded static/ directory
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatal(err)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
fs.Sub returns a new fs.FS rooted at the given subdirectory. http.FS wraps any fs.FS as an http.FileSystem. The whole thing is a few lines and the static files are compiled into the binary.
Embedding Database Migrations
This is where I get the most value day-to-day. I maintain Go services that use golang-migrate and previously had to either commit the migration files as a runtime artifact or go through the go-bindata dance:
import (
"embed"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations
var migrationFiles embed.FS
func runMigrations(db *sql.DB) error {
d, err := iofs.New(migrationFiles, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithSourceInstance("iofs", d, databaseURL)
if err != nil {
return err
}
return m.Up()
}
One binary. Migrations included. No deployment coordination required.
Module and Tooling Changes
Two quieter changes I care about:
GO111MODULE defaults to on. Module-aware mode is now the default everywhere, including outside of GOPATH. This should have been the default two versions ago. If you still have GO111MODULE=on in your environment or CI scripts, you can drop it.
go install now requires a version suffix for installing binaries outside a module. The old go get behavior for installing tools is deprecated. Use:
go install github.com/some/tool@v1.2.3
go install github.com/some/tool@latest
This is better. Tool installations are now reproducible and versioned. The old behavior where go get would silently update your go.mod when you wanted to install a CLI was genuinely confusing.
What I Actually Shipped
In production today: three services use embed.FS for migrations, two use it for HTML templates, one uses it for default JSON config files. I've removed go-bindata from all of them. The io/fs.FS pattern is now my standard approach for any function that reads files — the testability improvement alone is worth the refactor.
1.16 is not a headline release. No generics (that's 1.18), no major language changes. But embed and io/fs are the kind of features that quietly improve a codebase month after month once you start using them.