1. The Magic of Go Workspaces
  2. Finding a Bug
  3. Fixing the Bug
  4. Saving go.work, or Not
  5. Creating Libraries

Thursday, May 8, 2025

# The Magic of Go Workspaces

Go introduced workspaces in 1.18. Workspaces are incredibly useful, especially when working with libraries. Workspaces are the secret sauce for writing libraries in Go! However, I found the blog post that introduced them to be confusing, and I have found that many Go developers I have worked with do not know how they work.

First, let's explain the problem that Go workspaces solve.

Go modules are imported by their source control URLs. If you want to reference a module on disk instead of from the internet (with say, one you cloned directly using git), you need to change the module name and import path, which is a huge hassle. Of course you could avoid this if you pushed all your changes back to source control, but maybe you do not have commit access, or you want to make a change and test it before publishing.

Workspaces let you make changes to Go libraries without having to publish the changes to source control.

There are numerous use-cases for this:

  1. Fixing bugs in open source projects
  2. Creating a new library from existing project code
  3. Get a faster feedback loop for your own library changes that does not involve git push and go get

For the purpose of this article, I define "library" as "any go module that is kept in a separate repository from the main project". The prime example is any open source module you find on GitHub.

# Finding a Bug

Let's see how to fix a bug using Go workspaces.

Yesterday I was working with github.com/mdp/qrterminal, a fun library which creates QR codes in your terminal. It is a great way to send data (like URLs or configs) from your dev environment to your phone. Except yesterday I updated the lib and it panicked.

There was supposed to be a QR Code in this picture, but there is a stacktrace instead.

Since the panic happened after updating the library I had a reasonably good suspicion that it was not actually happening inside golang.org/x/term, and so I took a look at the IsSixelSupported function, which looks like this:

func IsSixelSupported(w io.Writer) bool {
	if w != os.Stdout {
		return false
	}
	stdout := os.Stdout
	if !term.IsTerminal(int(stdout.Fd())) {
		return false
	}
	_, err := stdout.Write([]byte("\x1B[c"))
	if err != nil {
		return false
	}
	buf := make([]byte, 1024)
	//set echo off
	raw, err := term.MakeRaw(int(stdout.Fd()))
	defer term.Restore(int(stdout.Fd()), raw)
	_, err = stdout.Read(buf)
	if err != nil {
		return false    <--------- panic reported here on line 70
	}
	for _, b := range string(buf) {
		if b == '4' {
			//Found Sixel Support
			return true
		}
	}
	return false
}

It is hard to imagine how return false can panic, but just above this is a defer call and just above that is an unhandled err :=. This means that the panic is actually happening in the defer after the return false on line 70 runs, because raw is nil.

	raw, err := term.MakeRaw(int(stdout.Fd()))
	defer term.Restore(int(stdout.Fd()), raw)  <---- raw is nil. Oopsie!

Fortunately, the fix is easy. We need to handle the error. Our new code should look like this:

	raw, err := term.MakeRaw(int(stdout.Fd()))
	if err != nil {
		return false
	}
	defer term.Restore(int(stdout.Fd()), raw)

# Fixing the Bug

Now that we know the fix, we just need to apply the fix, test, and submit a PR. To do that we will use a go workspace.

cd ~/code
git clone https://github.com/mdp/qrterminal  # <-- we are patching this
cd ~/code/turbine                    # <-- this is the main project
go work init                         # Create a go workspace
go work use .                        # Add the main project to the workspace
go work use ../qrterminal            # Add the forked library to the workspace

Now we have a go.work file with the following contents (with slashes going the wrong way because I am on Windows):

go 1.24.3

use (
        .
        ..\qrterminal
)
It seems redundant but if you do not include the dot (.), your current folder will not be included.

If we inspect the go.mod file that our go.work file is referencing, we see our cloned library.

$ head -n 1 ../qrterminal/go.mod
module github.com/mdp/qrterminal/v3

This means that anywhere github.com/mdp/qrterminal/v3 shows up in our imports, it will use the code found in the ../qrterminal instead of whatever code go get found on the internet. Critically, the module specified in go.mod, not the name of the folder, determines which import gets substituted.

Yay! Our project is now referencing a local copy of the library.

Now we just apply our patch:

	raw, err := term.MakeRaw(int(stdout.Fd()))
	if err != nil {
		return false
	}
	defer term.Restore(int(stdout.Fd()), raw)

... verify the change:

Working output from the QR Terminal module

... and submit a pull-request. 🚀

Once the pull-request is merged, we can delete go.work and go.work.sum, run go get github.com/mdp/qrterminal/v3@latest, and continue on our merry way.

# Saving go.work, or Not

What should I do with the go.work and go.work.sum files? Should I commit them?

Perhaps. I do not commit these, and I do not recommend that you commit them either. In the scenario above, the go.work file references local paths outside of git so reproducing that state on another machine is tricky.

If you are working on a personal project I think it is easy enough to leave the go.work uncommitted (perhaps ignored) on your own computer. You already have the patched version available to you until the maintainer can look at your PR.

If you are working in a corporate environment or you need to coordinate your changes across multiple machines and you cannot wait for upstream to merge your pull-request, you should create a fork with the patch, push that into your own source control system, update your projects to reference the fork, and make a TODO to circle back and switch back to the upstream repo once the change has landed. Yes, this is a lot more work, but it is reliable and will unblock you.

Depending on developer availability, CLA approvals, testing, release cycles, etc., it can take months to land a fix. Sometimes longer. But remember, open source developers mostly work for free, so just sit tight and wait to see if you are the lucky winner of maintaining your fork forever. 🙃

# Creating Libraries

I have written many libraries. It gives me warm, fuzzy feelings when other developers use my code. Sometimes they even say "Why did you do it this way?" or "I found a bug." or "Can you make it do X?" and that is how you know someone is using your library. And in Go, the workspace is the best tool for writing libraries.

As we saw above, workspaces enable you to write and iterate on a library without having to publish it. This means you can work out the API and make tweaks and test new features without having to git push and go get every time you change something, which is a huge time saver.

My favorite way to write libraries is to simply cut-and-paste working code from an existing project into a new package, and then change the imports.

We start by creating a new, empty Go project:

mkdir ~/code/newlibrary
cd ~/code/newlibrary
go mod init git.example.com/newlibrary
touch newlibrary.go

Then we go into an existing project and create our workspace:

cd ~/code/oldproject
go work init
go work use .
go work use ../newlibrary

Then we can start moving code, and reference our new library via:

package main

import (
	"git.example.com/newlibrary"
)

func main() {
	newlibrary.SomeFunction()
}

...even though we have not pushed the code anywhere!

Now it is super easy for us to iterate on our library, sort out the API, generalize functions, run tests, and we can publish when we are ready.


Related Notes