· 6 min read ·

Pure Go WebAssembly Tooling: The Design Case for watgo

Source: eli-bendersky

WebAssembly tooling has a clear lineage. wabt, the WebAssembly Binary Toolkit, has been the reference implementation for WAT-to-WASM conversion since the early days of the spec, written in C++ and maintained in lockstep with the WebAssembly specification. More recently, the Bytecode Alliance’s wasm-tools became the Rust ecosystem’s answer, offering a modular Rust API alongside CLI parity with wabt and support for newer features like the WebAssembly Component Model. Both are mature and well-maintained. Neither is particularly useful if you are writing a Go program that needs to process WebAssembly at the library level.

That is the gap Eli Bendersky’s watgo fills: a WAT parser, WASM validator, binary encoder, and decoder in pure, zero-dependency Go.

Why existing tools do not fully serve Go

If you need to process WebAssembly from a Go program, your options before watgo were roughly: shell out to wat2wasm or wasm-tools parse and parse stdout, use CGo bindings to libwabt, or find a partial Go implementation that covers your specific use case.

Each of these carries real costs. Spawning a subprocess ties your program to an installed external tool, adds process launch latency, and complicates distribution. CGo bindings to a C++ library are more invasive: you lose cross-compilation, the build requires a C++ toolchain to be present, and any CI environment needs the C++ library installed alongside the Go project. Binary size grows, debugging across the CGo boundary is unpleasant, and the build becomes fragile in ways Go programs usually are not. The Go ecosystem discourages CGo for libraries specifically because it poisons the experience for downstream consumers, who inherit all of those constraints just by importing your package.

Pure Go libraries avoid all of that. They cross-compile trivially with GOOS and GOARCH, they compose cleanly with go get, they work in any environment with a Go toolchain present, and they produce statically linked binaries without external dependencies. For a library that processes a well-specified binary format like WebAssembly, there is no fundamental technical barrier to a pure Go implementation; it just requires doing the work.

The four operations

watgo is organized around four core capabilities that mirror what wabt and wasm-tools offer at their core.

Parse converts WAT, the WebAssembly Text format, into wasmir, the toolkit’s semantic intermediate representation. WAT is the S-expression text representation of WebAssembly modules, the format you write by hand when working close to the metal:

(module
  (func (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

Validate checks that a module in wasmir form satisfies the official WebAssembly validation semantics, which cover type correctness of instructions, valid memory access structures, and well-formed import/export tables. Encode serializes wasmir into the WASM binary format that runtimes execute. Decode is the reverse: reading a .wasm binary back into wasmir.

Those four operations cover the full round-trip: text to binary and binary back to text, with validation in the middle.

wasmir: why the semantic IR matters

The decision to anchor the toolkit around a semantic IR rather than a raw parse tree is the most interesting architectural choice in watgo, because it is what makes this a toolkit rather than just a format converter.

A parse tree mirrors the syntax of the source. If WAT uses S-expressions with parentheses and keywords, the parse tree reflects those parentheses and keywords. A semantic IR strips that away and represents the module in terms of its actual structure: a list of function type signatures, a list of functions with their local variable declarations and instruction sequences, tables, memories, globals, imports, and exports. This maps directly to how the WebAssembly spec formally models a module in its abstract syntax.

That distinction matters as soon as you want to do anything with the module beyond converting it. A test harness that needs to assert whether a compiled function has the right parameter types wants the semantic representation, not a parse tree it has to walk looking for the right S-expression. A code generator that produces WebAssembly programmatically can construct wasmir directly and call encode, rather than building strings of WAT text and round-tripping through the parser. Validation logic maps cleanly onto the semantic model rather than requiring a separate interpretation pass over the syntax tree.

This is the same fundamental pattern that wasm-tools uses with its wasmparser crate, which produces a structured stream of typed, validated records rather than raw bytes. wabt does the same internally. Having a clean semantic model is table stakes for a toolkit that wants to be used as a library.

CLI and API

The watgo CLI aims for compatibility with wasm-tools, which is a sensible anchor since wasm-tools has become the primary reference for developers already familiar with the space. The core command is parse:

watgo parse stack.wat -o stack.wasm

This parses, validates, and encodes in a single step. Bendersky has already switched his wasm-wat-samples project to use it, which provides a concrete correctness signal: if samples previously built with wasm-tools produce identical output with watgo, the encoder is behaving correctly.

Installing the CLI follows the standard Go pattern:

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

The Go API pairs the watgo package with wasmir for inspection:

package main

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

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

func main() {
    mod, err := watgo.ParseText(wasmText)
    if err != nil {
        panic(err)
    }
    for _, f := range mod.Funcs {
        fmt.Printf("function: %d params\n", len(f.Type.Params))
    }
}

This opens up use cases that would otherwise require shelling out: custom linting over WebAssembly output, test harnesses that assert structural properties of compiled modules, or embedding a WAT-aware tool directly in a Go service.

Where Go developers actually need this

Demand for Go-native WebAssembly tooling is growing as WASI becomes a real deployment target. Go 1.21 added GOOS=wasip1, which compiles Go programs to WebAssembly modules targeting the WASI preview1 ABI. Tools that need to inspect or transform that output sit naturally in Go’s domain: wrappers, analysis tools, CI pipelines that enforce size or structural constraints on compiled modules.

Cloud platforms that run user-submitted WebAssembly, a pattern that has spread to serverless and plugin systems, are often written in Go. Wazero, a zero-dependency Go WebAssembly runtime, is the clearest example of this tendency. A toolkit like watgo fits naturally alongside a runtime like Wazero in a Go-native processing stack, where you might want to parse, validate, instrument, and then execute a module, all in the same process without shelling out.

TinyGo, which compiles a subset of Go to compact WebAssembly binaries for embedded and browser targets, is another corner of the ecosystem where Go-native tooling makes sense. Being able to parse and inspect TinyGo output from within a Go test suite, without depending on wabt or wasm-tools being installed, is the kind of improvement that proves its value immediately.

The zero-dependency constraint

Bendersky’s previous Go work, including libraries for parsing and encoding low-level formats, shows a consistent preference for self-contained implementations. The zero-dependency constraint on watgo is not just philosophical: it makes the library easier to justify as a dependency in projects with strict module policies, and easier for the library itself to be transitively imported without dragging in complexity.

The WebAssembly binary format is fully specified in the WebAssembly spec. The validation rules are formally defined. The text format has a clear grammar. None of this requires external libraries to implement; it is bounded, tractable work. The result is a library that does real work with no dependencies, which is the kind of thing that tends to find its way into Go projects precisely because it is so easy to add.

The WebAssembly tooling ecosystem has always had strong implementations at the C++ and Rust ends. The Go end is now better equipped.

Was this interesting?