When compilation gets in the way
You have a Go service that handles heavy data processing. A client wants to customize the filtering logic without waiting for your next deployment cycle. You hand them a .go file. Now you need to run it. The standard go run command works, but it compiles a new binary every time, burns CPU, and spawns a fresh process. You need the script to run inside your existing application, share memory with your main code, and start instantly.
Yaegi fills that gap. It is a standalone Go interpreter that executes Go source code directly in memory. The standard Go toolchain translates your code into machine instructions before execution. Yaegi skips the compilation step. It parses the source, builds an abstract syntax tree, and walks that tree to execute instructions line by line. Think of it like reading a recipe aloud while cooking, instead of printing the recipe, laminating it, and handing it to a separate kitchen.
Interpreters trade raw speed for flexibility. You lose the optimization passes that go build applies, but you gain the ability to load, modify, and run code at runtime. The interpreter stays alive in your process heap. It shares types, functions, and variables with your host application.
Keep the interpreter lean. Load only what the script needs.
How the interpreter actually works
When you feed Go source code to Yaegi, the engine performs three distinct phases. First, it tokenizes the text into keywords, identifiers, operators, and literals. Second, it constructs an abstract syntax tree that represents the program structure without executing anything. Third, the evaluation loop walks the tree. It resolves variable names, matches function signatures, and executes statements sequentially.
Unlike a compiled binary, the interpreter does not generate machine code. It maintains an execution context that tracks variable values, function call stacks, and type information. When the script finishes, the context clears. The process exits. No object files, no linker steps, no startup delay from loading a heavy binary.
The interpreter also bridges your host application and the script. You can register Go functions, types, and packages into the interpreter namespace. The script calls them as if they were native. The host receives results as standard Go values. The boundary is transparent but deliberate.
Treat the interpreter as a guest in your process. Give it exactly what it needs and nothing more.
Minimal example: running a script from the command line
Start with the standalone CLI tool. It proves the concept before you embed anything.
Here is the setup for a standalone script execution:
// script.go
// Defines a simple function to test interpreter execution
func main() {
// Prints a greeting to verify the interpreter runs Go code
println("Yaegi is running this script in memory")
}
Install the interpreter once and run it like any other CLI tool:
# Fetches and installs the yaegi binary to your GOPATH/bin
go install github.com/traefik/yaegi/cmd/yaegi@latest
# Executes the script without producing a compiled binary
yaegi script.go
The output appears immediately. No temporary executable sits on your disk. The interpreter reads the file, parses it, and executes it in a single pass.
Run the CLI first. Verify the script works before embedding it.
Walkthrough: parsing, evaluating, and executing
When you run yaegi script.go, the toolchain does three things. First, it tokenizes the source code into keywords, identifiers, and operators. Second, it builds an abstract syntax tree that represents the program structure. Third, the interpreter engine walks the tree. It resolves variable names, matches function signatures, and executes statements sequentially.
Unlike a compiled binary, the interpreter does not generate machine code. It maintains an execution context that tracks variable values, function call stacks, and type information. When the script finishes, the context clears. The process exits. No object files, no linker steps, no startup delay from loading a heavy binary.
The evaluation loop handles control flow by jumping between tree nodes. A for loop becomes a conditional branch that checks a counter, executes the body, and jumps back. A function call pushes a new frame onto the call stack, binds arguments, executes the body, and pops the frame when returning. The interpreter mimics the Go runtime but does it in software instead of hardware.
This software simulation is why interpreters are slower. Every statement requires AST traversal and dynamic type resolution. The tradeoff is acceptable when you value flexibility over raw throughput.
Measure before optimizing. Most script workloads are I/O bound, not CPU bound.
Realistic example: embedding the interpreter in a service
The real value appears when you embed the interpreter inside a long-running service. You can expose host functions to the script, let the script call your database layer, and catch errors without crashing the main process.
Here is how to embed Yaegi and bridge host code with script code:
package main
import (
"fmt"
"github.com/traefik/yaegi/interp"
)
// HostFunc demonstrates a function exposed to the interpreted script
func HostFunc(msg string) string {
// Returns a transformed string to prove host-script communication
return fmt.Sprintf("Host received: %s", msg)
}
func main() {
// Creates a new interpreter instance with standard library access
i := interp.New(interp.Options{})
// Registers the host function under the name "hostFunc" for the script
i.Use(interp.Symbols)
i.Use(interp.Exports{"main": map[string]interface{}{"hostFunc": HostFunc}})
// Evaluates a code string that calls the exposed host function
_, err := i.Eval(`
package main
func main() {
result := hostFunc("dynamic payload")
println(result)
}
`)
// Handles evaluation failures without panicking the host process
if err != nil {
fmt.Println("Script failed:", err)
}
}
The i.Use() calls wire your Go types into the interpreter namespace. The script sees hostFunc as if it were written in the same package. The interpreter resolves the call, passes the string to your host function, and returns the result. The host and script share the same address space.
Go developers expect explicit error handling. The if err != nil { return err } pattern is verbose by design. Keep that pattern in your host wrapper functions. The interpreter will not catch panics automatically. Wrap your Eval() calls in defer recover() if you want to isolate script crashes from your main goroutine.
Public names start with a capital letter. Private names start lowercase. When you expose functions to Yaegi, only capitalized names are visible to the script unless you explicitly register them. The interpreter respects Go visibility rules. Name your host functions carefully.
Isolate the interpreter. Reset it between script runs to avoid state bleeding.
Bridging host types and script values
Crossing the interpreter boundary requires type awareness. When you pass a Go value into Eval(), the interpreter wraps it in a dynamic representation. When the script returns a value, the interpreter unwraps it back into a Go interface. This wrapping adds overhead but keeps type safety intact.
You can expose entire packages by registering their exported symbols. The interpreter maps package names to Go types. If you expose a database/sql.DB pointer, the script can call db.Query() directly. The interpreter routes the call to the real method. It does not copy the database connection. It shares the pointer.
Be careful with mutable state. If a script modifies a slice or map that your host also uses, race conditions appear. The interpreter runs in the same goroutine as your host unless you explicitly spawn a new one. Concurrency inside the script follows standard Go rules, but the interpreter does not enforce synchronization across the boundary.
Protect shared state. Pass copies or use channels when scripts mutate host data.
Pitfalls and runtime failures
Interpreters introduce specific failure modes. Performance is the first. A tight loop in compiled Go runs in nanoseconds. The same loop in Yaegi runs in microseconds because every statement requires AST traversal and dynamic type resolution. Do not run compute-heavy workloads through the interpreter.
Security is the second. Running untrusted code inside your process gives that code access to your memory space. The interpreter does not sandbox by default. A malicious script can read environment variables, open network connections, or panic your host process. Validate input, restrict standard library access, and run untrusted scripts in isolated containers if security matters.
Memory management requires attention. The interpreter keeps loaded packages and evaluated code in memory. If you evaluate thousands of unique scripts without clearing the state, your heap grows until the garbage collector cannot keep up. Reset the interpreter or spawn a fresh instance for long-running workloads.
The compiler rejects invalid Go syntax with expected ';', found 'EOF' or undefined: variableName. Yaegi mirrors standard Go errors but surfaces them at runtime instead of build time. If you pass a wrong argument type to an exposed function, the interpreter panics with interface conversion: interface {} is string, not int. Catch these errors in your Eval() call and log them. The worst interpreter bug is the one that silently swallows a type mismatch and corrupts downstream data.
Log every script failure. Silent errors hide until production breaks.
Decision: when to use Yaegi versus alternatives
Use Yaegi when you need to load and execute Go code at runtime without restarting your application. Use the standard go build toolchain when you need maximum performance and are deploying static binaries. Use the built-in plugin package when you want compiled shared libraries that load at startup but still require a build step. Use a dedicated scripting language like Lua or JavaScript when your users expect dynamic typing, rapid iteration, and a smaller memory footprint.
Match the tool to the constraint. Speed demands compilation. Flexibility demands interpretation.