· 5 min read ·

Go's WebAssembly Tooling Gap Gets a Pure-Go Answer in watgo

Source: eli-bendersky

Go’s WebAssembly story has developed along two fairly distinct tracks. On the execution side, wazero provides a zero-dependency pure-Go WASM runtime, the official Go compiler has supported WASM output via GOOS=wasip1 since Go 1.21, and TinyGo produces compact binaries for edge and embedded targets. On the tooling side, a gap has persisted: if you need to parse, inspect, or generate WebAssembly from Go code without leaving the Go ecosystem, there has not been a clean answer.

Eli Bendersky’s watgo addresses this directly. It is a pure, zero-dependency Go library and CLI for working with the WebAssembly Text Format (WAT) and WASM binary format. The library handles four operations: parsing WAT text into an in-memory module representation, validating modules against the official WebAssembly specification, encoding to WASM binary, and decoding WASM binary back into the same module representation. At the center of all four is wasmir, a semantic IR that represents a WebAssembly module as a typed Go struct tree.

The Existing Toolkit Landscape

wabt, the WebAssembly Binary Toolkit, is the reference implementation maintained by the WebAssembly working group. Written in C++, it provides wat2wasm, wasm2wat, wasm-objdump, wasm-validate, and a reference interpreter. The C API allows embedding, but calling it from Go requires CGo, a C++ toolchain, and the cross-compilation friction that accompanies both.

wasm-tools from the Bytecode Alliance is the Rust ecosystem’s answer. The underlying crates, particularly wasmparser and wasm-encoder, are used inside Wasmtime and several major browser engines. wasm-tools also covers the Component Model and WIT (WebAssembly Interface Types), making it the more comprehensive choice for modern WASM proposals. Using it from Go requires Rust FFI.

Both tools work well in their respective ecosystems. Using them from Go requires either CGo with a C++ or Rust toolchain, or shelling out to a subprocess. Those approaches work, but they are not the Go-native experience that the language’s module system and cross-compilation support are built around.

What watgo Provides

The four operations map cleanly to the WASM format lifecycle. A module starts as human-readable WAT, gets validated against the spec, and gets serialized to binary for execution. The reverse path, decoding a binary back to an inspectable in-memory form, enables analysis of binaries produced by other toolchains.

The CLI installs with the standard Go toolchain:

go install github.com/eliben/watgo/cmd/watgo@latest

Converting a WAT file to WASM binary is a single command:

watgo parse stack.wat -o stack.wasm

Bendersky notes that the CLI is designed to be compatible with wasm-tools, and he has already migrated his wasm-wat-samples project to use it. That compatibility goal has practical value: teams using wasm-tools in build scripts can switch to watgo without rewriting their pipelines, and the installation story becomes simpler since there is no Rust toolchain requirement.

The wasmir Layer

The Go API is built around wasmir, which represents the more interesting design decision in the library. Many WebAssembly libraries approach the format as a byte stream. Rust’s wasmparser, for example, is an iterator-style streaming parser that yields events without materializing the full module. That approach is extremely fast and memory-efficient, but it produces a sequence you process in a single pass. For analysis or code generation, a navigable in-memory representation is more ergonomic.

wasmir represents a WebAssembly module as a struct tree that mirrors the module structure from the spec: function type signatures, function bodies, tables, linear memories, globals, imports, exports, element segments, data segments. The Go API from Bendersky’s post shows the shape:

package main

import (
    "fmt"

    "github.com/eliben/watgo"
)

const wasmText = `
(module
  (func (export "add") (param i32) (param i32) (result i32)
    local.get 0
    local.get 1
    i32.add))`

func main() {
    mod, err := watgo.ParseWAT([]byte(wasmText))
    if err != nil {
        panic(err)
    }
    fmt.Println(len(mod.Funcs), "function(s)")
    fmt.Println(len(mod.Exports), "export(s)")
}

The separation between front-end operations (Parse WAT, Decode binary) and back-end operations (Validate, Encode) with wasmir as the common representation is the right architecture for a toolkit. A Go program can load a WASM binary from disk, walk its exports, modify something in the struct tree, and re-encode it, all without treating the binary as an opaque blob.

The tradeoff is memory. Materializing a full module as a struct tree costs more than streaming over the bytes. For typical WASM modules, which are not large by binary standards, this is acceptable. For a high-throughput validation pipeline processing thousands of binaries per second, a streaming approach would be more appropriate; watgo is not targeting that use case, and being explicit about that boundary is part of good library design.

Zero Dependencies in Go

The zero-dependency claim carries real weight in the Go module ecosystem. A library with no external dependencies installs via go get without additional system requirements, compiles with CGO_ENABLED=0 for fully static binaries, and cross-compiles freely to any GOOS/GOARCH combination without needing a matching native toolchain.

For a WASM toolkit, this matters beyond convenience. If you are building a Go-based compiler backend or analysis tool that produces WASM output, adding a CGo dependency creates an awkward situation: the tool now requires a C toolchain wherever it builds, and CGo disables Go’s built-in cross-compilation, which is one of the language’s most practical features for toolchain authors.

Bendersky’s prior Go libraries follow the same design discipline consistently: go-peparser for PE files, his ELF and DWARF utilities. Pure Go, clear API, comprehensive tests, minimal scope. The consistency reflects a considered position about what makes a library genuinely portable and maintainable over time.

Practical Use Cases

For teams that use Go throughout their stack, watgo opens up workflows that previously required external tooling.

The clearest case is automated analysis of WASM output. If your pipeline produces WASM binaries via TinyGo, the official compiler, or another toolchain, you can write Go tests that decode the output, verify expected exports exist, confirm no unexpected imports were introduced, or measure basic metrics like function count and code section size. The analysis lives inside the Go test suite rather than in a separate shell script.

A second case is WASM-based plugin systems. It is increasingly common for Go host applications to load WASM modules as plugins using runtimes like wazero. Before handing an incoming plugin binary to the runtime, watgo allows the host to decode and validate the module, checking that it conforms to the expected interface without executing it.

Third, watgo is useful when building or testing a WebAssembly runtime in Go. Generating test cases programmatically by constructing wasmir modules in Go code and encoding them to binary is cleaner than maintaining a corpus of hand-written WAT files or driving an external tool from within the test process.

Where watgo Fits

watgo does not include a WASM interpreter, Component Model support, or WIT parsing. wasm-tools remains the right choice for those needs. The scope is narrower and more focused: the core format operations for the base WebAssembly spec, implemented cleanly in pure Go.

A library with a defined scope is easier to audit, easier to maintain, and less likely to accumulate the kind of incremental complexity that makes a project hard to depend on over time. If Component Model support becomes important later, it can be published as a companion module without compromising the core design.

For Go developers who have been invoking wasm-tools via exec.Command or maintaining a CGo wrapper just to handle WAT files, watgo is worth evaluating. It covers the common case without friction, cross-compiles cleanly, and fits into the Go toolchain the way good Go libraries should.

Was this interesting?