Skip to main content

Embedding

gopy is a Go module. A host Go program can compile and run Python source without spawning a subprocess, without CGo, and without a Python install on the machine. The interpreter is one import away.

This page walks through the embedding surface: minimal run, package map, registering a built-in module, exchanging values between Go and Python, sandboxing user-supplied code, and the concurrency model.

A minimal embed​

main.go
package main

import (
"fmt"
"log"
"os"

"github.com/tamnd/gopy/compile"
"github.com/tamnd/gopy/parser"
"github.com/tamnd/gopy/pythonrun"
"github.com/tamnd/gopy/state"

// Blank import to wire the stdlib modules gopy ships.
_ "github.com/tamnd/gopy/stdlibinit"
)

func main() {
src := `name = "world"
print(f"hello, {name}")
`
mod, err := parser.ParseString(src, "<embedded>", parser.ModeFile)
if err != nil {
log.Fatal(err)
}
code, err := compile.Compile(mod, "<embedded>", 0)
if err != nil {
log.Fatal(err)
}
ts := state.NewThread()
if _, err := pythonrun.Run(ts, code, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
$ go run .
hello, world

The blank _ "github.com/tamnd/gopy/stdlibinit" import is how the standard modules (sys, io, math, re, ...) register themselves with the import machinery. Drop it and you get a minimal interpreter with only built-in names; useful when you want to control exactly what user code can reach.

A shorter RunSimpleString​

If you do not need to keep the compiled Code object around, the one-shot helper composes the three calls above:

ts := state.NewThread()
globals := objects.NewDict()
err := pythonrun.RunSimpleString(ts, src, globals, os.Stderr)

globals is the module dict the code runs in. Anything the script assigns at module scope ends up in it; anything you want the script to see (functions, configuration, host data) you put in before the call.

Package surface​

The embedding surface is small. These are the packages you will import from a host program:

PackageRole
parserTokenise and parse Python source into an AST.
compileCompile an AST into a Code object.
pythonrunRun a Code object on the VM; one-shot helpers.
statePer-thread interpreter state.
objectsThe value model: Object, Type, Dict, Str, Int, ...
errorsException types and the bridge to Go error.
impThe import machinery (AppendInittab, ImportModule).
stdlibinitBlank-import to wire every shipped stdlib module.
Source pathEntry points
parser/parser.goParseString, ParseFile, ModeFile, ModeEval, ModeSingle
compile/compile.goCompile(mod, filename, flags) (*Code, error)
pythonrun/pythonrun.goRunSimpleString, RunAnyFile, Run, InteractiveLoop
state/state.goNewThread, Thread.*
objects/NewDict, NewInt, NewStr, NewList, NewTuple, ...
imp/inittab.goAppendInittab, ExtendInittab, FindInitFunc

Registering a built-in module​

A host can add a Python-callable module by registering it with the import system. The pattern matches how every shipped stdlib module wires itself up.

mybuiltin/init.go
package mybuiltin

import (
"github.com/tamnd/gopy/imp"
"github.com/tamnd/gopy/objects"
)

func init() {
imp.AppendInittab("mybuiltin", initModule)
}

func initModule() (*objects.Module, error) {
m := objects.NewModule("mybuiltin")
m.SetAttr("answer", objects.NewInt(42))
m.SetAttr("greet", objects.NewBuiltinFunction("greet", greet))
return m, nil
}

func greet(args []objects.Object, kwargs *objects.Dict) (objects.Object, error) {
if len(args) != 1 {
return nil, objects.TypeError("greet() takes exactly one argument")
}
name, ok := args[0].(*objects.Str)
if !ok {
return nil, objects.TypeError("greet() argument must be str")
}
return objects.NewStr("hello, " + name.String()), nil
}

Blank-import it from main:

import _ "example.com/yourapp/mybuiltin"
>>> import mybuiltin
>>> mybuiltin.answer
42
>>> mybuiltin.greet("gopy")
'hello, gopy'

The init() block runs at process start; AppendInittab is thread-safe (guarded by a sync.RWMutex in imp/inittab.go). Names registered later override earlier ones with the same key.

Exchanging values​

Every Python value gopy sees is an objects.Object. The constructors and accessors look like this:

// Go -> Python
i := objects.NewInt(42)
s := objects.NewStr("hi")
b := objects.NewBool(true)
n := objects.None
lst := objects.NewList()
lst.Append(i)
lst.Append(s)
dict := objects.NewDict()
dict.SetItemString("key", lst)
// Python -> Go
switch v := value.(type) {
case *objects.Int:
n, _ := v.Int64()
fmt.Println("got int:", n)
case *objects.Str:
fmt.Println("got str:", v.String())
case *objects.List:
for i := 0; i < v.Len(); i++ {
fmt.Println("item:", v.GetItem(i))
}
}

For complex shapes the cleanest pattern is a thin Go struct on either side of the bridge and a marshal helper that walks fields. For ad-hoc data, JSON via the _json module is usually the shortest path: the Python side json.dumps, the Go side encoding/json against the resulting bytes.

Calling a Python function from Go​

// Pre-compile once.
src := `def add(a, b): return a + b`
mod, _ := parser.ParseString(src, "lib", parser.ModeFile)
code, _ := compile.Compile(mod, "lib", 0)

ts := state.NewThread()
globals := objects.NewDict()
if _, err := pythonrun.Run(ts, code, os.Stderr); err != nil {
log.Fatal(err)
}

// Look up the function and call it many times.
addFn, _ := globals.GetItemString("add")
result, err := addFn.Call(ts, []objects.Object{
objects.NewInt(2),
objects.NewInt(3),
}, nil)

globals is what pythonrun.Run populated; any module-scope definition (def, class, top-level assignment) is now a key in it.

Capturing stdout​

var buf bytes.Buffer
ts := state.NewThread()
pythonrun.RunSimpleString(ts, `print("captured")`, objects.NewDict(), &buf)
fmt.Println(buf.String()) // "captured\n"

The third argument to pythonrun.RunSimpleString is the io.Writer sys.stdout writes to. Pass io.Discard to silence output; pass a bytes.Buffer to capture; pass os.Stdout to forward to the terminal.

Sandboxing user-supplied Python​

gopy is a strong sandbox by default: there is no subprocess, no ctypes, no os.system, and open() only sees the host's filesystem because the _io/os modules are wired by stdlibinit. To run untrusted Python:

  1. Skip stdlibinit. Don't import it. Register only the modules you trust.
  2. Strip dangerous builtins. del globals["__builtins__"].__import__ prevents arbitrary module loading once globals is populated. Or replace __builtins__ with a curated dict before pythonrun.Run.
  3. Wrap I/O. Replace sys.stdout/sys.stderr with writers that enforce size limits. Use io.LimitReader on inputs.
  4. Time-bound execution. Run on a goroutine with a context.Context; check the eval-breaker bit from a watchdog. The hooks live in state/breaker.go.

A worked sandbox example:

ts := state.NewThread()
globals := objects.NewDict()
builtins := objects.NewDict()
for _, name := range []string{"len", "range", "print", "abs", "min", "max"} {
if fn, _ := imp.GetBuiltin(name); fn != nil {
builtins.SetItemString(name, fn)
}
}
globals.SetItemString("__builtins__", builtins)
err := pythonrun.RunSimpleString(ts, userSource, globals, &output)

The script can call len, range, print, abs, min, max. It cannot import, cannot open files, cannot escape.

Errors​

A Python exception comes back as a Go error whose dynamic type is *errors.PyError:

_, err := pythonrun.Run(ts, code, os.Stderr)
if err != nil {
var pyErr *errors.PyError
if errors.As(err, &pyErr) {
fmt.Println("exception class:", pyErr.Type().Name())
fmt.Println("message:", pyErr.Value().String())
// pyErr.Traceback() yields the Python-level frames.
}
}

Returning errors from a Go-implemented built-in works the other way: return objects.TypeError(msg), objects.ValueError(msg), or any other constructor in errors/. The runtime raises the matching Python exception in the calling Python frame.

Concurrency​

A state.Thread is the unit of execution. Each goroutine that calls into the VM needs its own Thread:

ts := state.NewThread() // main goroutine
go func() {
ts2 := state.NewThread() // separate goroutine
pythonrun.RunSimpleString(ts2, src, globals, os.Stderr)
}()

The GIL lives in gil/. It is acquired automatically when the VM enters a frame and released around blocking native calls (time.sleep, network I/O). Multiple Threads in the same interpreter take turns holding it. cooperative multitasking, the same way CPython 3.14 behaves on the GIL build.

For true parallelism, create multiple interpreters; the per-interpreter GIL (PEP 684) plumbing is on the roadmap. Today the recommendation is one interpreter, multiple threads, with the GIL released during compute-heavy Go work.

Performance considerations​

  • Compile once, call many. A Code object can be re-run on fresh globals; parsing and compiling on every call dwarfs the execution cost.
  • Reuse Thread. state.NewThread() is not free; keep a pool if your service handles short calls at high rate.
  • Avoid the GIL for hot Go work. Wrap Go-side compute in gil.Release()/gil.Acquire() to let other Python threads run.
  • Watch the specializer. A warm code object is much faster than a cold one; if you spin up new interpreters for every request, you pay the cold-start tax every time. Embedding services that share an interpreter benefit from warm sites.

See Performance for current absolute numbers.

Reference​

  • cmd/gopy/main.go. The canonical wiring. Read it once.
  • pythonrun/pythonrun.go. The one-shot helpers.
  • imp/inittab.go. Module registration.
  • objects/. The full value-model surface.
  • Recipes for worked patterns.