../

Text format (WAT)

The WebAssembly text format for reading compiler output and writing small modules by hand: S-expression syntax, types and numeric instructions, control flow, calls, memory, tables, imports and exports, SIMD, references and exceptions, and the tools that convert and inspect binaries. The JS side is in Fundamentals.

Syntax basics

add.wat
(module
  ;; line comment            (; block comment ;)
  (func $add (export "add") (param $a i32) (param $b i32)
    (result i32)
    local.get $a
    local.get $b
    i32.add)
)
ElementSyntaxNotes
S-expression(keyword field*)every module field is a parenthesised list
Identifier$namesymbolic name for an index; any index can also be written as a number
Line comment;; textto end of line
Block comment(; text ;)nests
Integers42, -1, 0xff, 1_000_000sign and signedness chosen by the instruction, not the literal
Floats1.5, 0x1p-3, inf, -inf, nan, nan:0x200000hex floats are exact
Strings"hi\n", "\00\ff", "\u{1F600}"\hh byte escapes; used for names and data
Annotations(@name "x"), (@custom "sec" "bytes")custom annotation syntax (Wasm 3.0); tools may ignore
File extensions.wat (module), .wast (spec test scripts).wast adds assert_return etc.

wasm fences on this site highlight WAT. The binary format is canonical: WAT is for people and tests, and wasm-tools print / wasm2wat regenerate it from any .wasm.

Module fields

FieldExampleBinary section
type(type $bin (func (param i32 i32) (result i32)))type
import(import "env" "log" (func $log (param i32)))import
func(func $f (param i32) (result i32) ...)function + code
table(table $t 4 funcref)table
memory(memory $m 1 16)memory (min / max pages)
global(global $g (mut i32) (i32.const 0))global
tag(tag $err (param i32))tag
export(export "add" (func $add))export
start(start $init)start
elem(elem (i32.const 0) $f $g)element
data(data (i32.const 16) "hello")data

Inline shorthands save repetition: (func (export "f") ...), (memory (export "memory") 1), (func $log (import "env" "log") (param i32)), and (memory (data "abc")) sizes a memory to its data.

Index spaces: imported items come before defined ones, so the first defined function is not index 0 if the module imports a function. Names avoid this bookkeeping.

Folded vs flat instructions

;; flat (stack) form: what the binary encodes
local.get $x
i32.const 1
i32.add
local.set $x
 
;; folded form: operands nested, evaluated left to right
(local.set $x
  (i32.add (local.get $x) (i32.const 1)))
 
;; mixed is fine: folded expressions just push values
(i32.add (local.get $x) (i32.const 1))
local.set $x

Both forms compile to identical bytes. wasm-tools print -f (fold) and wasm2wat --fold-exprs produce the folded form. Validation tracks a typed operand stack: every block must leave exactly its declared result types, which is why the compiler inserts drop after unused results.

Types and numeric instructions

GroupInstructions (i32. shown; i64. identical)
Constantsi32.const 7, i64.const -1, f32.const 1.5, f64.const 0x1p-1
Arithmeticadd, sub, mul, div_s, div_u, rem_s, rem_u (divide by zero traps)
Bitwiseand, or, xor, shl, shr_s, shr_u, rotl, rotr
Bit countingclz, ctz, popcnt
Testseqz (returns i32 0 / 1)
Comparisoneq, ne, lt_s, lt_u, gt_s, gt_u, le_s, le_u, ge_s, ge_u
Sign extensioni32.extend8_s, i32.extend16_s, i64.extend32_s
Float arithmeticf64.add, sub, mul, div, sqrt, min, max, abs, neg, copysign
Float roundingceil, floor, trunc, nearest (ties to even)
Float comparisoneq, ne, lt, gt, le, ge (no _s / _u)
ConversionMeaning
i32.wrap_i64keep the low 32 bits
i64.extend_i32_s / _uwiden, sign or zero extend
i32.trunc_f64_s / _ufloat to int; traps on NaN or overflow
i32.trunc_sat_f64_s / _usaturating: NaN → 0, clamps (what JS-like code wants)
f64.convert_i32_s / _uint to float
f32.demote_f64, f64.promote_f32float width
i32.reinterpret_f32, f32.reinterpret_i32same bits, other type

Other stack instructions: drop discards a value, select picks one of two by an i32 condition ((select (result externref) ...) for reference types), nop does nothing, unreachable traps.

Locals and globals

(module
  (global $count (mut i32) (i32.const 0))
  (global $limit i32 (i32.const 100))  ;; immutable
 
  (func (export "bump") (param $by i32) (result i32)
    (local $tmp i32) (local $f f64)    ;; zero-initialized
    (local.set $tmp
      (i32.add (global.get $count) (local.get $by)))
    (global.set $count (local.get $tmp))
    local.get $tmp)                    ;; result
)
InstructionEffect
local.get $xpush a local (params are locals 0..n-1)
local.set $xpop into a local
local.tee $xset and keep the value on the stack
global.get $g / global.set $gglobals; set only on (mut ...)

Locals are declared at the top of a function, after params and results. Non-nullable reference locals must be set before use (validation checks this).

Control flow

ConstructBranch target (br)Notes
block $l ... endjumps to the enda forward break
loop $l ... endjumps to the starta continue; falls out at end
if (result t) ... else ... endend of the ifcondition is an i32 popped first
br $lunconditionallabel name or depth (0 = innermost)
br_if $lpops i32; branches if non-zerocommon loop test
br_table $a $b $defaultpops an indexa jump table (switch)
returnleave the functionvalues on top of the stack are the results
unreachabletrapmarks impossible paths (panic, abort)
(module
  ;; n! with a loop: block = break target, loop = continue
  (func (export "fact") (param $n i32) (result i64)
    (local $acc i64)
    (local.set $acc (i64.const 1))
    (block $done
      (loop $next
        (br_if $done (i32.le_s (local.get $n) (i32.const 1)))
        (local.set $acc
          (i64.mul (local.get $acc)
                   (i64.extend_i32_u (local.get $n))))
        (local.set $n (i32.sub (local.get $n) (i32.const 1)))
        (br $next)))
    local.get $acc)
 
  ;; if / else with a result value
  (func (export "sign") (param $x i32) (result i32)
    (if (result i32) (i32.lt_s (local.get $x) (i32.const 0))
      (then (i32.const -1))
      (else (i32.const 1))))
)
;; switch via br_table: 0 → 10, 1 → 20, other → 99
(func $pick (param $i i32) (result i32)
  (block $default
    (block $one
      (block $zero
        (br_table $zero $one $default (local.get $i)))
      (return (i32.const 10)))    ;; after $zero
    (return (i32.const 20)))      ;; after $one
  i32.const 99)

Blocks can take parameters and return several values (multi-value): (block (param i32) (result i32 i32) ...).

Calls

InstructionUse
call $fdirect call; arguments popped in order, results pushed
call_indirect $table (type $sig)pops an i32 table index; traps if the slot is null or the signature differs
return_call $ftail call (Wasm 3.0): reuses the frame, no stack growth
return_call_indirecttail call through a table
call_ref $sigcall a typed function reference (Wasm 3.0)
return_call_ref $sigtail call a function reference
ref.func $fa funcref to $f (needs $f declared in an elem, or exported)
(module
  (type $binop (func (param i32 i32) (result i32)))
  (table $ops 2 funcref)
  (elem (table $ops) (i32.const 0) func $add $mul)
  (func $add (type $binop)
    (i32.add (local.get 0) (local.get 1)))
  (func $mul (type $binop)
    (i32.mul (local.get 0) (local.get 1)))
 
  ;; apply(op, a, b): op 0 = add, 1 = mul
  (func (export "apply")
    (param $op i32) (param $a i32) (param $b i32)
    (result i32)
    (call_indirect $ops (type $binop)
      (local.get $a) (local.get $b) (local.get $op)))
)

This is how C function pointers and Rust dyn Trait vtables compile: the "pointer" is a table index.

Memory instructions

(module
  (memory (export "memory") 1 100)   ;; 64 KiB .. 6.25 MiB
  (data (i32.const 0) "Hello, Wasm!")
  (data $blob "\01\02\03\04")         ;; passive segment
 
  (func (export "sum") (param $p i32) (param $n i32)
    (result i32)
    (local $acc i32) (local $end i32)
    (local.set $end
      (i32.add (local.get $p)
               (i32.shl (local.get $n) (i32.const 2))))
    (block $out
      (loop $go
        (br_if $out
          (i32.ge_u (local.get $p) (local.get $end)))
        (local.set $acc
          (i32.add (local.get $acc)
                   (i32.load (local.get $p))))
        (local.set $p (i32.add (local.get $p) (i32.const 4)))
        (br $go)))
    local.get $acc)
)
InstructionEffect
i32.load offset=8 align=4read at address + offset; align is a hint (power of two, bytes)
i32.load8_s / load8_u / load16_s / load16_unarrow load with sign or zero extension
i64.load32_u32 bits into an i64
i32.store, i32.store8, i64.store32, f64.storepop address then value
memory.sizecurrent size in pages
memory.growpops delta pages, pushes the old size or -1 on failure
memory.fill(dest, byte, len); bulk memory
memory.copy(dest, src, len); overlapping is allowed (memmove)
memory.init $segcopy from a passive data segment
data.drop $segfree a passive segment
v128.load, v128.store16-byte SIMD access

Addresses are unsigned i32 (or i64 with (memory i64 1)); out-of-bounds access traps. The static offset= is added without wrap-around, so compilers fold struct field offsets into it. With several memories, name the memory: (i32.load $m2 (local.get $p)) or memory.copy $dst $src.

Active data segments ((data (i32.const 16) ...)) are copied at instantiation; passive ones (no offset) wait for memory.init, which is how threaded programs initialize memory once.

Tables and elements

FormMeaning
(table $t 10 funcref)10 null function references
(table $h 0 externref)table of host values, grown at runtime
(elem (i32.const 0) $f $g)active: fill slots 0 and 1 of table 0
(elem $e func $f $g)passive segment, used by table.init
(elem declare func $f)declarative: only allows ref.func $f
table.get $t / table.set $tread / write a slot
table.size, table.grow, table.fill, table.copylike the memory versions
table.init $t $e, elem.drop $ebulk from a segment
ref.null func, ref.null extern, ref.is_nullnull references

Imports, exports, start

(module
  ;; imports come first in each index space
  (import "env" "log" (func $log (param i32)))
  (import "env" "memory" (memory 1))
  (import "env" "base" (global $base i32))
  (import "env" "tbl" (table 4 funcref))
 
  (global $ready (mut i32) (i32.const 0))
 
  (func $init
    (global.set $ready (i32.const 1))
    (call $log (global.get $base)))
  (start $init)       ;; runs during instantiation
 
  (func (export "ready") (result i32) (global.get $ready))
  (export "ready_flag" (global $ready))
)
const { instance } = await WebAssembly.instantiate(bytes, {
  env: {
    log: console.log,
    memory: new WebAssembly.Memory({ initial: 1 }),
    base: 42, // immutable i32 global: a plain number
    tbl: new WebAssembly.Table({
      element: "anyfunc", initial: 4,
    }),
  },
});

The start function takes no params, returns nothing and runs before instantiate resolves; it cannot call exports that JS has not seen yet, and a trap there rejects instantiation. Toolchains usually prefer an explicit exported _initialize / _start instead.

SIMD taste

(module
  (memory (export "memory") 1)
  ;; out[i] = a[i] * b[i] for 4 floats at a time
  (func (export "mul4") (param $a i32) (param $b i32)
    (param $out i32)
    (v128.store (local.get $out)
      (f32x4.mul (v128.load (local.get $a))
                 (v128.load (local.get $b)))))
 
  ;; horizontal: lane extraction
  (func (export "lane2") (param $p i32) (result f32)
    (f32x4.extract_lane 2 (v128.load (local.get $p))))
)
Instruction familyExamples
Constants and splatv128.const i32x4 1 2 3 4, f32x4.splat, i8x16.splat
Lane accessi32x4.extract_lane 0, f32x4.replace_lane 3
Arithmetici32x4.add, f32x4.mul, f64x2.sqrt, i16x8.add_sat_s
Compare and selectf32x4.lt, v128.bitselect, v128.any_true, i32x4.all_true
Shufflei8x16.shuffle 0 1 ... 15, i8x16.swizzle
Relaxed (Wasm 3.0)f32x4.relaxed_madd, i32x4.relaxed_trunc_f32x4_s: faster, hardware-dependent edge cases

Compilers auto-vectorize when you enable the feature (-msimd128, -C target-feature=+simd128).

References and GC

(module
  (type $point (struct (field $x (mut f64))
                       (field $y (mut f64))))
  (type $bytes (array (mut i8)))
 
  (func (export "make") (param f64 f64) (result anyref)
    (struct.new $point (local.get 0) (local.get 1)))
 
  (func (export "x") (param $p anyref) (result f64)
    (struct.get $point $x
      (ref.cast (ref $point) (local.get $p))))
 
  (func (export "buf") (param $n i32) (result i32)
    (array.len
      (array.new $bytes (i32.const 0) (local.get $n))))
)
InstructionMeaning
struct.new $t, struct.get $t $f, struct.set $t $fallocate and access GC structs
array.new $t, array.new_fixed $t n, array.get, array.set, array.lenGC arrays
ref.i31, i31.get_s / i31.get_uunboxed small integers as references
ref.test (ref $t), ref.cast (ref $t)type test / checked downcast (cast traps)
br_on_cast, br_on_null, br_on_non_nullbranch on the dynamic type or nullness
ref.as_non_nulltrap on null, otherwise narrow the type
any.convert_extern, extern.convert_anymove references between the JS and GC hierarchies

GC objects are collected by the host engine and are opaque to JS: export accessor functions.

Exceptions

(module
  (tag $oops (export "oops") (param i32))
 
  (func $risky (param $x i32)
    (if (i32.lt_s (local.get $x) (i32.const 0))
      (then (throw $oops (local.get $x)))))
 
  ;; returns the payload if $risky threw, else 0
  (func (export "check") (param $x i32) (result i32)
    (block $caught (result i32)
      (try_table (catch $oops $caught)
        (call $risky (local.get $x)))
      (i32.const 0)))
)
SyntaxMeaning
(tag $t (param ...))exception type; export it to test for it from JS
throw $tthrow with the payload on the stack
try_table (catch $t $l) ...on $t, branch to $l with the payload
(catch_ref $t $l)also pass an exnref for rethrowing
(catch_all $l), (catch_all_ref $l)any exception, including JS ones
throw_refrethrow an exnref

This is the Wasm 3.0 (exnref) form. Older binaries use the legacy try / catch / delegate instructions, which engines still accept but which are no longer being standardized.

Tools

Taskwasm-tools (Bytecode Alliance)wabtbinaryen
WAT → Wasmwasm-tools parse a.wat -o a.wasmwat2wasm a.wat -o a.wasmwasm-as a.wat
Wasm → WATwasm-tools print a.wasmwasm2wat a.wasmwasm-dis a.wasm
Validatewasm-tools validate --features all a.wasmwasm-validate a.wasm(on load)
Sections / headerswasm-tools objdump a.wasmwasm-objdump -h / -x
Disassemble with offsetswasm-tools print -p / wasm-tools dumpwasm-objdump -d
Strip custom sectionswasm-tools strip a.wasmwasm-strip a.wasmwasm-opt --strip-debug
Demangle nameswasm-tools demangle
Interpretwasm-interp a.wasm --run-all-exportswasm-shell
Optimizewasm-opt -O3 / -Oz
C-like viewwasm-decompile a.wasm
To JSwasm2js
Componentswasm-tools component new / wit
  • wasm-tools follows the newest proposals fastest (it is the Wasmtime parser) and is the safest choice for Wasm 3.0 features; by default it enables every phase-4+ proposal.
  • wabt is the classic kit; its wasm-interp is handy for quick tests. Newer proposals may need --enable-* flags or lag behind.
  • binaryen's wasm-opt is the optimizer behind Emscripten and wasm-pack; run it on any toolchain output. Enable the features your output uses (--enable-simd, --enable-gc, or -all).
  • wasmtime runs .wat directly: wasmtime run --invoke fact fact.wat 5.

Reading compiled output

# sizes and imports / exports at a glance
wasm-tools objdump app.wasm
wasm-tools print app.wasm | grep -E '^\s*\((import|export)'
 
# one function, folded, with byte offsets
wasm-tools print -f -p app.wasm | less
 
# where did the bytes go? (see also twiggy for Rust)
wasm-opt --func-metrics app.wasm -o /dev/null | head
 
# producers: which compiler and version built it
wasm-tools metadata show app.wasm
You seeIt usually means
(import "wbg" "__wbg_...")wasm-bindgen glue imports
(import "env" "emscripten_..."), invoke_*Emscripten runtime; invoke_* wraps calls for JS-based exceptions or setjmp
(import "wasi_snapshot_preview1" ...)WASI preview 1 syscalls
(global $__stack_pointer (mut i32) ...)LLVM shadow stack for address-taken locals
__heap_base, __data_endlinker symbols bounding static data
call_indirect everywherefunction pointers, trait objects, vtables
unreachable after a calla panic / abort path the compiler proved never returns

Recipes

Hello world with a JS import

Use as the smallest end-to-end test of a toolchain and loader.

hello.wat
(module
  (import "env" "log" (func $log (param i32 i32)))
  (memory (export "memory") 1)
  (data (i32.const 0) "Hello from WAT")
  (func (export "main")
    (call $log (i32.const 0) (i32.const 14)))
)
const res = await fetch("/hello.wasm");
const bytes = await res.arrayBuffer();
let mem!: WebAssembly.Memory;
const { instance } = await WebAssembly.instantiate(bytes, {
  env: {
    log: (ptr: number, len: number) => console.log(
      new TextDecoder().decode(
        new Uint8Array(mem.buffer, ptr, len)),
    ),
  },
});
mem = instance.exports.memory as WebAssembly.Memory;
(instance.exports.main as () => void)();

Compile WAT in Node

Use for unit tests of hand-written snippets; the wabt npm package is wabt compiled to Wasm.

import initWabt from "wabt";
 
const wabt = await initWabt();
const src = `(module (func (export "sq")
  (param i32) (result i32)
  (i32.mul (local.get 0) (local.get 0))))`;
const mod = wabt.parseWat("sq.wat", src);
const { buffer } = mod.toBinary({});
const { instance } = await WebAssembly.instantiate(buffer);
const sq = instance.exports.sq as (x: number) => number;
console.log(sq(12)); // 144

A bump allocator

Use when hand-written modules must accept strings or arrays from JS (alloc then write then call).

(module
  (memory (export "memory") 1)
  (global $top (mut i32) (i32.const 1024)) ;; after data
 
  (func (export "alloc") (param $n i32) (result i32)
    (local $p i32)
    (local.set $p (global.get $top))
    ;; round up to 8 bytes
    (global.set $top
      (i32.and (i32.add (i32.add (local.get $p)
                                 (local.get $n))
                        (i32.const 7))
               (i32.const -8)))
    ;; grow if needed: pages = ceil(top / 65536)
    (if (i32.gt_u (global.get $top)
          (i32.shl (memory.size) (i32.const 16)))
      (then (drop (memory.grow (i32.const 1)))))
    local.get $p)
 
  (func (export "reset") (global.set $top (i32.const 1024)))
)

Fill and copy memory fast

Use bulk-memory instructions instead of byte loops (memset / memmove compile to these).

(module
  (memory (export "memory") 1)
  (func (export "clear") (param $p i32) (param $n i32)
    (memory.fill
      (local.get $p) (i32.const 0) (local.get $n)))
  (func (export "move")
    (param $dst i32) (param $src i32) (param $n i32)
    (memory.copy
      (local.get $dst) (local.get $src) (local.get $n)))
)

Tail-recursive loop

Use return_call for recursion that must not overflow the stack (Wasm 3.0 tail calls).

(module
  (func $sum (export "sum") (param $n i64) (param $acc i64)
    (result i64)
    (if (result i64) (i64.eqz (local.get $n))
      (then (local.get $acc))
      (else
        (return_call $sum
          (i64.sub (local.get $n) (i64.const 1))
          (i64.add (local.get $acc) (local.get $n))))))
)

Round-trip a binary

Use to check what a toolchain really emitted or to patch a module by hand.

wasm-tools print app.wasm -o app.wat   # binary → text
$EDITOR app.wat
wasm-tools parse app.wat -o app2.wasm  # text → binary
wasm-tools validate app2.wasm
wasm-opt -Oz app2.wasm -o app.min.wasm
ls -l app.wasm app.min.wasm

References