Native Go Scripts

2025-12-30

Everything looks like a nail when you have a hammer (or something like that). I write a lot of Go code and generally prefer statically typed languages over dynamically typed ones. Unfortunately Go wasn’t really designed as a scripting language but go run is already tempting as you can use it to run most go scripts as long as you don’t require external dependencies.

Shebang

A common hack relies on the fact that shells often try to run text files as shell scripts when they can’t be executed through the exec* syscall. The files then often look something like this1:

//usr/local/bin/go run "$0" "$@"; exit
package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

Since the shell will fall back to interpreting the file as a script and consecutive slashes are treated as one it will happily execute go with the script path. But most editors will run go fmt on the file and insert a space between the // and the path breaking it.

binfmt_misc2

On Linux there is a much more powerful system to modify how files are executed. binfmt_misc allows you to register interpreters for all sorts of files. First, we need an interpreter we can register to execute our scripts. The simplest interpreter would be:

#!/bin/sh
exec go run "$@"

Save this to /usr/local/bin/go-run and make it executable. Next we need to register our handler with binfmt_misc. Check if the binfmt_misc file system is mounted (run mount without any arguments and check if it’s listed). If not, mount it:

sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc

Once it’s mounted you can proceed to register the interpreter:

echo ":go:E::go::/usr/local/bin/go-run:" | sudo tee /proc/sys/fs/binfmt_misc/register

The first go is the name of our entry, it just needs to be unique, E specifies that we want to match on file extensions, the second go is the file extension we want to match, and the path is our interpreter to run the script.

Now you can execute any Go file by marking it as executable, just as if it were a compiled binary!

To persist these changes across reboots you should make sure your system mounts the file system early on and that something registers your interpreter. Systemd offers binfmt.d3 to do that for you.


  1. https://lorentz.app/blog-item.html?id=go-shebang↩︎

  2. https://docs.kernel.org/admin-guide/binfmt-misc.html↩︎

  3. https://man.archlinux.org/man/binfmt.d.5↩︎