By Marcos Nils, Docker Captain, Hashicorp Ambassador, Go developer, and co-creator of play-with-go.dev.

Sometimes, when a new go version is released, it also ships with a bunch of changes and really interesting features in the standard library. At the time of this article, go 1.16 has been released around two months ago which introduces some changes and new features into the core library like the new io.FS interface, the go:embed directive amongst others.

As a module author, how could I introduce these new features and at the same time provide some guarantees that my module can still support the last N releases of go?

This guide explains how to deal with situations where you want to use new features of recent versions of go and at the same time you don’t want to force downstream dependats to upgrade. In this case we’ll be using conditional compilation through build tags in a real life scenario by using 1.16’s new io.FS whilst retaining compatibility for users of go 1.15.

Here’s a high level overview of the steps you’ll accomplish following this guide:

  • Create a public module that contains and exports a DoSomething() function
  • Create a gopher module that uses the publicmodule
  • Bump the public module to use go 1.16 features without any considerations
  • Try updating the gopher module and show the observed errors
  • Fix the public module by adding required buid tags.

A simple go 1.15 program using ioutil.Discard

Verifying that we’re using go 1.15 version:

$ go115 version
go version go1.15.8 linux/amd64

Now, we’ll also check that we have go 1.16 installed as well:

$ go116 version
go version go1.16.3 linux/amd64

We’ll start by setting go 1.15 as the default working version:

$ alias go=go115

Start by a new public module:

$ mkdir /home/gopher/public
$ cd /home/gopher/public
$ go mod init {{{.PUBLIC}}}
go: creating new go.mod: module {{{.PUBLIC}}}
$ git init -q
$ git remote add origin https://{{{.PUBLIC}}}.git

Now, we’ll upload a simple go program that uses 1.15 ioutil.Discard in a public function:

Create an initial version of the DoSomething() in public.go:

package public

import (
    "fmt"
    "io/ioutil"
)

func DoSomething() {
    fmt.Fprintf(ioutil.Discard, "This doesn't print anything")
}

Commit and push this initial version:

$ git add public.go go.mod
$ git commit -q -m 'Initial commit of public module'
$ git push -q origin main
remote: . Processing 1 references        
remote: Processed 1 references in total        

The gopher module

Now create a gopher module to try out the public module. Unlike the public module, you will not publish the gopher module; it will be local only:

$ mkdir /home/gopher/gopher
$ cd /home/gopher/gopher
$ go mod init gopher
go: creating new go.mod: module gopher

Create an initial version of a main package that uses the previous public module, in gopher.go:

package main

import (

"{{{.PUBLIC}}}"
)

func main() {
    public.DoSomething()
}

Get the initial version of the gopher module:

$ go get -d {{{.PUBLIC}}}@latest
go: downloading {{{.PUBLIC}}} v0.0.0-20060102150405-abcedf12345
go: {{{.PUBLIC}}} latest => v0.0.0-20060102150405-abcedf12345

Now, we’ll run the gopher module main package:

$ go run .

Bumping the public dependency to go 1.16

Go 1.16 has been released, and as good developers we want to refactor our code so it uses the new io.Discard variable instead of the deprecated ioutil one.

First, we set our go version to 1.16:

$ alias go=go116

Now, we change our original public module to use the new variable.

package public

import (
    "fmt"
    "io"
)

func DoSomething() {
    fmt.Fprintf(io.Discard, "This doesn't print anything")
}

Now we’re good to release a new version of our public module using the go 1.16 new package:

$ cd /home/gopher/public
$ git add public.go go.mod
$ git commit -q -m 'Bump public to go1.16'
$ git push -q origin main
remote: . Processing 1 references        
remote: Processed 1 references in total        

Let’s go back to our gopher module in go 1.15, fetch the latest version of the public dependecy and see what happens when we try and run again:

$ alias go=go115
$ cd /home/gopher/gopher
$ GOPROXY=direct go get -d {{{.PUBLIC}}}@latest
go: {{{.PUBLIC}}} latest => v0.0.0-20060102150405-abcedf12345
go: downloading {{{.PUBLIC}}} v0.0.0-20060102150405-abcedf12345
$ go run .
# {{{.PUBLIC}}}
../go/pkg/mod/{{{.PUBLIC}}}@v0.0.0-20060102150405-abcedf12345/public.go:9:17: undefined: io.Discard

As you can see, when trying to run our gopher project using the latest public, we got an error because in our case, we’re still using go 1.15 in the gopher project which doesn’t support the new io.Discard package.

How do we handle these situations where we shouldn’t force our clients to update? The right approach to tackle this is by using build constraints so our public clients can build their project regardless of the go version they’re using

Adding build tags to our public project

Let’s go ahead and modify our public project so it now uses the // +build 1.16 tag.

First we rollback the changes to our original file to keep using the ioutil.Discard pacage for go < 1.16 clients

// +build !go1.16

package public

import (
    "fmt"
    "io/ioutil"
)

func DoSomething() {
    fmt.Fprintf(ioutil.Discard, "This doesn't print anything")
}

Additionally, we create a new file which has the correct build tag, so newer clients can make use of the new package

// +build go.1.16

package public

import (
    "fmt"
    "io"
)

func DoSomething() {
    fmt.Fprintf(io.Discard, "This doesn't print anything")
}

Let’s now publish our fixed module

$ cd /home/gopher/public
$ git add public.go public_116.go go.mod
$ git commit -q -m 'Fix public bump to go1.16'
$ git push -q origin main
remote: . Processing 1 references        
remote: Processed 1 references in total        

Now, we can go ahead and update our gopher without problems with the benefit that go 1.16 clients and future clients will be able to use make use of the newer go features and packages.

$ cd /home/gopher/gopher
$ GOPROXY=direct go get -d {{{.PUBLIC}}}@latest
go: {{{.PUBLIC}}} latest => v0.0.0-20060102150405-abcedf12345
go: downloading {{{.PUBLIC}}} v0.0.0-20060102150405-abcedf12345
$ go run .

Conclusion

This guide serves as an example on how to leverage on build constraints to provide a backwards compatbile module to your clients. Build constraints are a very powerful pattern to achieve other tasks in go. We encourage the reader to check the official docs for further examples.

As a next step you might like to consider: