· 6 min read ·

Pure Go WebAssembly Tooling and the FFI Tax watgo Avoids

Source: eli-bendersky

Eli Bendersky released watgo, a pure-Go WebAssembly toolkit covering WAT text parsing, Wasm binary encoding and decoding, and a semantic in-memory IR called wasmir. The project positions itself alongside wabt (C++) and wasm-tools (Rust), but the zero-dependency Go angle is the thing that matters most for people who build Go tools.

Before getting into what watgo does, it helps to understand the gap it fills.

The FFI Tax in Go

Go has strong WebAssembly support as a compilation target. Since Go 1.21, GOOS=wasip1 GOARCH=wasm produces WASI-preview-1 compliant binaries, and TinyGo has been generating compact Wasm for edge and embedded use for years. But Go as a tooling language for WebAssembly has been poorly served. If you want to write a Go program that analyzes or transforms a .wasm binary, your options before watgo were limited:

  1. Shell out to wat2wasm or wasm-tools as subprocesses
  2. Call wabt or wasm-tools through CGo bindings
  3. Write your own parser

CGo is the obvious choice for capability, but it comes with real costs. Cross-compilation becomes painful. Build times increase. You take on the burden of linking platform-specific native libraries. Static builds get complicated. Go’s toolchain loses a lot of its simplicity the moment CGo enters the picture.

Wazero, the pure-Go Wasm runtime, has an internal binary parser, but it lives under internal/wasm/binary/ and is explicitly not a public API. The project is a runtime, not a toolkit, and the internal code reflects that.

watgo is the first serious pure-Go library aimed squarely at the tooling side of WebAssembly: reading, writing, and structurally examining Wasm modules without any native dependencies.

What the Toolkit Actually Covers

The core of watgo is wasmir, a semantic representation of a WebAssembly module as idiomatic Go structs. A raw .wasm binary is a sequence of sections (Type, Import, Function, Table, Memory, Global, Export, Code, Data, and Custom), and cross-referencing between them is done with integer indices. That works for the binary format but makes analysis code tedious. wasmir links everything together into a Go struct tree where a Function directly holds its type, locals, and instruction body rather than raw offsets into separate sections.

Built on top of that IR, watgo provides four operations:

  • Parse: reads WAT text (WebAssembly Text format) and produces a wasmir.Module
  • Validate: checks the module against the official WebAssembly validation semantics
  • Encode: serializes a wasmir.Module to binary .wasm
  • Decode: reads binary .wasm back into a wasmir.Module

The CLI mirrors the interface of wasm-tools:

go install github.com/eliben/watgo/cmd/watgo@latest
watgo parse stack.wat -o stack.wasm

The Go library API looks like this for analysis work:

package main

import (
    "fmt"
    "github.com/eliben/watgo"
    "github.com/eliben/watgo/wasmir"
)

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)
    }
    for _, f := range mod.Functions {
        fmt.Printf("function: %d instructions\n", len(f.Body))
    }
}

This is the kind of code you’d write to build a Wasm analyzer, optimizer, or transformer entirely in Go.

How It Compares to wabt and wasm-tools

The WebAssembly Binary Toolkit (wabt) is the reference C++ toolkit, maintained under the WebAssembly GitHub organization. It has been around since the WebAssembly MVP in 2015-2016 and covers everything: wat2wasm, wasm2wat, a validator, an interpreter, wasm-decompile, wasm2c, and tooling for the spec test format. It implements new proposals early, often before the spec is finalized, using feature flags. wabt is what proposal authors use to test their spec text.

wasm-tools is the Bytecode Alliance’s Rust-native toolkit and has been the more aggressive project over the past few years. Its foundational crate, wasmparser, is a zero-copy streaming parser that avoids building a full in-memory tree. Instead of a tree, it emits events as it reads bytes, similar to a SAX XML parser. This makes it extremely fast and memory-efficient for large Wasm binaries, which is why it’s used internally by Wasmtime, wasm-bindgen, and cargo-component. The tradeoff is that writing analysis code against it is less ergonomic than working with a tree. wasm-tools also contains wit-parser and wit-component, making it the canonical toolchain for the Component Model proposal.

wabtwasm-toolswatgo
LanguageC++RustGo
Parser styleFull IR treeStreaming, zero-copyFull IR tree
ValidatorSpec-completeSpec-completeCore + common proposals
InterpreterYesNoNo
Component ModelMinimalComprehensiveNo
Go embeddingCGo requiredCGo requiredNative
MaturityVery matureMatureEarly stage

watgo’s IR design is philosophically closer to wabt than to wasm-tools. Both build a full in-memory tree. That trades allocation efficiency for ergonomics, which is the right call for tooling where you want to traverse and transform the module rather than stream through it once.

The WAT Format and Why Parsing It Is Non-Trivial

The WebAssembly Text format is s-expression based, which sounds simple until you get into the details. WAT has two forms for writing instructions: flat (stack-machine order, matching the binary layout exactly) and folded (nested s-expressions where arguments are sub-expressions). Both are valid, and most tools support both.

The folded form looks like this:

(i32.add
  (local.get $x)
  (local.get $y))

The flat form looks like this:

local.get $x
local.get $y
i32.add

Both produce the same binary. The assembler needs to understand Wasm’s type system and stack semantics to linearize the folded form correctly. That means a WAT parser is not just an s-expression reader; it needs to typecheck as it goes. A pure-Go implementation of this is a meaningful engineering exercise.

WAT identifiers (the $name syntax) are another wrinkle. They exist only in the text format and get preserved in the binary’s name custom section, which is non-standard but universally supported. A complete parser needs to handle identifier resolution, scoping, and the name section encoding.

What’s Missing

watgo is early-stage by the author’s own description. The most significant gaps compared to wabt and wasm-tools are interpreter support, Component Model coverage, and WIT handling.

The Component Model is the biggest forward-looking piece. The Component Model defines a way to compose Wasm modules with typed interfaces, described in WIT (WebAssembly Interface Types) files. wasm-tools has become the primary toolchain for this through wit-parser, wit-component, and wasm-compose. No Go-native library covers this terrain yet. For teams building Go tooling around the Component Model, there is still no alternative to shelling out to wasm-tools or using its Rust crates.

The validator in watgo covers the core MVP semantics and common proposals, but spec-complete validation for the full proposal suite (SIMD, threads, GC, exceptions, tail calls) may be incomplete. This matters less for the primary use case of building Go developer tools and more for anyone trying to use watgo as a conformance checker.

Why This Project Exists

Eli Bendersky’s open source catalog (pycparser, go-readelf, and many others) consistently has an educational character. The code is meant to be read, not just used. watgo is a Go-native implementation of a non-trivial specification, and reading the source is a reasonable way to learn both the WAT format and the binary Wasm layout. The wasmir package in particular demonstrates how to model a cross-referential binary format as an ergonomic Go struct tree, which is a useful pattern beyond WebAssembly.

From a practical standpoint, Go is a common language for writing developer tooling: build systems, linters, code generators, language servers. As more of those tools interact with Wasm (either because they compile to it or because they process it), having a pure-Go library that parses and transforms Wasm modules without a CGo dependency is genuinely useful. wazero demonstrated that a pure-Go Wasm runtime was viable and worth building. watgo is the complementary argument for the tooling side.

The project is not trying to replace wabt or wasm-tools for their primary audiences. It is trying to make a specific thing easier: writing Go programs that work with Wasm binaries as structured data. For that narrower purpose, zero native dependencies and idiomatic Go APIs matter more than streaming performance or Component Model coverage.

Getting Started

Install the CLI:

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

Add the library:

go get github.com/eliben/watgo

The source is at github.com/eliben/watgo. For the broader WebAssembly tooling context, the wabt repository and the wasm-tools crates are worth reading alongside watgo if you want to understand the design space.

For Go developers who have been waiting for a non-CGo way to work with Wasm binaries structurally, watgo is the project to watch.

Was this interesting?