WebAssembly Text Format (WAT) Basics
Most modules you ship are emitted by a compiler — Rust, C, or AssemblyScript — and you never look at the instructions. But the moment you need to debug a trap, shave a few bytes off a hot function, or understand what a binary actually does, you drop down to WebAssembly Text Format. WAT is the official S-expression rendering of a .wasm binary: a one-to-one textual view of the same module structure the engine executes. Learning to read it turns the binary from an opaque blob into something you can inspect, edit, and verify by hand.
This guide walks through the WAT syntax that matters in practice — module, func, param, result, local, memory, export, and import — explains the value-stack model that every instruction operates on, shows the difference between folded and flat instruction styles, and takes you through the round-trip workflow: write WAT, compile with wat2wasm, then load the result from JavaScript. The companion guide below builds your first complete module from scratch.
Prerequisites
- [ ]
wabt(WebAssembly Binary Toolkit) installed, providingwat2wasm,wasm2wat, andwasm-validate—brew install wabt,apt install wabt, ornpm install -g wabt - [ ]
wat2wasm --versionprints a version (1.0.30 or newer recommended) - [ ] Node.js 18+ (or any modern browser) to load and run the compiled
.wasm - [ ] Optional:
wasmtimefor running and disassembling modules outside the browser - [ ] A text editor — no project scaffolding required; a single
.watfile is enough
How WAT maps to the binary
A WAT module is a tree of S-expressions. Each top-level field — types, functions, memory, exports, imports — corresponds to a numbered section in the binary encoding. The text is human-friendly; the binary is a compact stream where every integer is LEB128-encoded and every instruction is a single opcode byte. wat2wasm is the deterministic transform between them.
The mapping is reversible: wasm2wat decodes a binary back to text, which is how you read modules a compiler produced. The encoding details — opcode layout, LEB128, and section ordering — are covered in the Wasm binary format deep dive. What matters at the WAT level is that the structure you see in the parentheses is the structure of the binary, just spelled out.
The workflow: write, compile, run
The full loop has three steps, each a single command or file. Keep them in muscle memory; you repeat them every time you touch WAT.
-
Write the module in a
.watfile. Start with(module ...)and add functions, memory, and exports.(module (func (export "add") (param $a i32) (param $b i32) (result i32) local.get $a local.get $b i32.add)) -
Compile to binary with
wat2wasm. This parses, type-checks, and encodes the module in one pass.wat2wasm add.wat -o add.wasm -
Load and call the compiled module from a host — JavaScript in the browser or Node, or
wasmtimeon the command line.wasmtime --invoke add add.wasm 2 3 # prints 5
If wat2wasm exits silently, compilation succeeded and add.wasm is on disk. Any type or syntax problem prints a line-and-column error and produces no output — there is no partial binary to clean up.
Reading an annotated module
Below is a single module that exercises every field you need in everyday WAT. Each line is commented; read it top to bottom and you have the working vocabulary of the format.
(module
;; import a host function: module "env", field "log", takes one i32, returns nothing
(import "env" "log" (func $log (param i32)))
;; declare one page of linear memory (64 KiB) and export it so JS can read/write bytes
(memory (export "memory") 1)
;; a static string written into memory at byte offset 0 when the module instantiates
(data (i32.const 0) "hi")
;; exported function: two i32 params, one i32 result
(func (export "add") (param $a i32) (param $b i32) (result i32)
;; $a and $b are named locals bound to the parameters
local.get $a ;; push $a onto the value stack -> [a]
local.get $b ;; push $b -> [a, b]
i32.add) ;; pop two, push their sum -> [a+b] (this is the result)
;; a function with its own local variable and a call into the host
(func (export "shout") (result i32)
(local $count i32) ;; declare a mutable i32 local, initialized to 0
i32.const 2
local.set $count ;; $count = 2 -> []
i32.const 72 ;; the byte value 'H'
call $log ;; call the imported host function with 72
local.get $count)) ;; leave $count on the stack as the result
A few things to internalize from this module:
funcis the unit of code. Its signature isparamtypes followed by an optionalresulttype. The body must leave exactly the declared results on the value stack and nothing more.paramandlocalboth name slots. Parameters are the incoming arguments; locals are scratch variables youlocal.set/local.get. Both are typed and zero-initialized (locals) or caller-supplied (params).memorydeclareslinear memoryin units of 64 KiBpages — here, one page minimum. Exporting it lets the host construct typed-array views over the same bytes.importnames a two-level key (moduleandfield) the host must satisfy through theimport objectat instantiation time.exportdoes the reverse, exposing a function or memory under a string name.
The value stack
Every instruction is a stack operation. Constants and local.get push operands; arithmetic and store instructions pop them. i32.add pops two i32s and pushes one. A function returns by leaving its result values on the stack when the body ends. This is the essence of the stack-based VM execution model: there are no registers in the bytecode, only a typed operand stack and a fixed set of locals. If you ever see a wat2wasm error about stack height, it means an instruction left the stack in a state the function signature did not promise.
Folded vs flat instructions
WAT accepts two equivalent spellings of the same instructions. Flat form lists instructions one per line in execution order — the order they push and pop. Folded form nests operands inside parentheses, which reads more like an expression tree. They compile to byte-identical binaries.
;; flat form: explicit push order
local.get $a
local.get $b
i32.add
;; folded form: identical semantics, expression-tree layout
(i32.add (local.get $a) (local.get $b))
Folded form is easier to read for nested arithmetic; flat form makes the stack discipline obvious and matches what wasm2wat emits. Mixing both in one function is legal and common.
Loading the compiled module from JavaScript
Once add.wasm exists, the host instantiates it, supplies any imports through the import object, and calls the exports. The integers you pass are the only values that cross the call boundary; anything larger travels through the exported memory.
const importObject = {
env: { log: (x) => console.log("wasm log:", x) },
};
const bytes = await fetch("/add.wasm").then((r) => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(bytes, importObject);
console.log(instance.exports.add(2, 3)); // 5
// read the bytes the (data ...) segment wrote into linear memory
const mem = new Uint8Array(instance.exports.memory.buffer);
console.log(mem[0], mem[1]); // 104 105 -> "hi"
The import object’s shape must match the module’s imports exactly: a missing env.log makes instantiation throw a LinkError. How that wiring happens, and the difference between streaming and buffer instantiation, is detailed in the broader WebAssembly Core Concepts & Browser Runtime material.
Tradeoffs: hand-written WAT vs compiler output
Writing WAT by hand is the right call for small, surgical work — a 20-instruction hot loop, a glue function the compiler over-generates, or a test fixture you need to be exact. You get byte-level control and zero toolchain between you and the binary. The cost is that WAT has no abstractions: no structs, no generics, no borrow checker, and manual stack bookkeeping. A 200-line function is painful and easy to get wrong.
Compiler output (Rust via wasm-pack, C via Emscripten) scales to real programs and gives you a type system, but it emits verbose modules with allocator boilerplate and conservative codegen. The practical workflow is hybrid: let the compiler produce the module, use wasm2wat to read what it emitted, and hand-edit only the hot path when profiling justifies it. Treat hand-WAT as a scalpel, not a language to build applications in.
Gotchas and failure modes
- Type mismatch errors.
wat2wasmrejects(i32.add (f32.const 1.0) (i32.const 2))withtype mismatch in i32.add, expected [i32 i32] but got [f32 i32]. The fix is to make operand types match the instruction — Wasm never coerces silently. - Stack height at function end. If your body leaves the wrong number of values on the stack, you get
type mismatch in function, expected [i32] but got [](forgot to push a result) or... but got [i32 i32](left an extra value). Every path through the function must end with exactly the declaredresulttypes on the stack. - Export name typos.
instance.exports.ad(2, 3)isundefinedand throwsnot a functionif you exported"add"but calledad. The compiler cannot catch this — the binding is by string. Confirm names withwasm-objdump -xor by loggingObject.keys(instance.exports). - Missing imports. If the module declares
(import "env" "log" ...)and yourimport objecthas noenv.log, instantiation throwsLinkError: import object field 'log' is not a Function. Supply every import the module names.
Verification
Two checks confirm a module is structurally sound before you ship it. First, validate it directly:
wasm-validate add.wasm # exits 0 and prints nothing when the module is valid
Second, run a round-trip to confirm wat2wasm produced what you intended. Disassemble the binary back to text and compare:
wat2wasm add.wat -o add.wasm
wasm2wat add.wasm -o roundtrip.wat
cat roundtrip.wat # canonicalized WAT — confirms structure, types, and exports
The round-tripped WAT is canonicalized (flat instructions, numeric type indices), so it will not match your source character-for-character, but the function signatures, memory declaration, and export names must all be present. For a byte-level look at the resulting binary, wasm-objdump -x add.wasm lists every section, and the decoding Wasm files manually guide shows how to read the raw bytes.
In this guide
- Writing your first WAT module by hand — a step-by-step tutorial that builds an
addfunction and a memory-using function, compiles them withwat2wasm, and calls them from JavaScript.
Frequently Asked Questions
Do I need to write WAT to use WebAssembly?
No. Almost all production modules come straight from a compiler, and you can ship Wasm without reading a single S-expression. WAT becomes valuable when you need to debug a binary, optimize a hot path by hand, or understand exactly what your compiler emitted — at which point wasm2wat and a working knowledge of the format save hours.
What is the difference between wat2wasm and wasm2wat?
wat2wasm compiles text to binary (the direction you use when authoring by hand); wasm2wat disassembles a binary back to text (the direction you use to read a compiler’s output). Both ship with wabt and are exact inverses up to canonicalization — round-tripping preserves semantics but normalizes formatting.
Why does my function fail to compile when it looks correct?
The most common cause is a stack-height or type mismatch: the function body left the wrong values on the value stack, or an instruction received an operand of the wrong numeric type. WAT has no implicit conversions, so an f32 where an i32 is expected is an error, not a coercion. Read the error’s expected [...] but got [...] line — it tells you exactly which stack state was wrong.
Can I import JavaScript functions into a hand-written WAT module?
Yes. Declare (import "module" "field" (func ...)) with a matching signature, then provide { module: { field: fn } } in the import object at instantiation. The two-level name and the function arity must match exactly or instantiation throws a LinkError.
Related
- Wasm binary format deep dive — how WAT’s sections encode to
LEB128-packed binary bytes. - Stack vs heap execution model — the value stack and
linear memorythat every WAT instruction operates on. - Wasm instantiation lifecycle — how the
import objectis bound when the compiled module loads. - Writing your first WAT module by hand — the hands-on companion tutorial.