Skip to content

Plugin System Advanced

Introduction

Go's standard plugin package provides dynamic loading of shared libraries at runtime — similar to dlopen in C. You compile Go code into a .so file with -buildmode=plugin, then load it at runtime with plugin.Open and look up exported symbols.

In practice, most Go projects don't use the plugin package. It's Linux-only (and macOS with caveats), requires exact Go version and dependency alignment between host and plugin, doesn't support unloading, and complicates deployment. Production plugin systems in Go almost always use one of the alternatives: RPC-based plugins (hashicorp/go-plugin), gRPC-based plugins, or build-time interface registration.

Understanding both the standard package and its alternatives is essential for designing extensible Go systems.

Syntax & Usage

Building a Plugin

A plugin is a Go main package compiled with -buildmode=plugin:

// plugins/greeter/greeter.go
package main

import "fmt"

type Greeter struct{}

func (g Greeter) Greet(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

// Exported variable — must be package-level
var GreeterPlugin Greeter

func Hello(name string) string {
    return fmt.Sprintf("Hi there, %s!", name)
}
go build -buildmode=plugin -o plugins/greeter.so ./plugins/greeter/

Loading and Using a Plugin

package main

import (
    "fmt"
    "plugin"
)

type GreeterInterface interface {
    Greet(name string) string
}

func main() {
    p, err := plugin.Open("plugins/greeter.so")
    if err != nil {
        panic(err)
    }

    // Look up an exported function
    helloSym, err := p.Lookup("Hello")
    if err != nil {
        panic(err)
    }
    helloFunc := helloSym.(func(string) string)
    fmt.Println(helloFunc("World"))

    // Look up an exported variable and use via interface
    greeterSym, err := p.Lookup("GreeterPlugin")
    if err != nil {
        panic(err)
    }
    greeter := greeterSym.(GreeterInterface)
    fmt.Println(greeter.Greet("Alice"))
}

plugin Package API

Function / Method Purpose
plugin.Open(path) Load a .so plugin file, returns *Plugin
(*Plugin).Lookup(name) Find an exported symbol by name, returns plugin.Symbol (any)
Type assertion on Symbol Cast to the expected function or interface type

Limitations of the plugin Package

Limitation Impact
Linux-only (macOS with caveats) Not portable — no Windows, no Alpine/musl
Exact Go version match Plugin and host must use identical Go compiler version
Identical dependency versions Shared dependencies must be at exactly the same version
No unloading Once loaded, plugins can never be freed from memory
No Windows support Eliminates a major deployment target
-buildmode=plugin only Cannot use static binaries (CGO_ENABLED=0)
Debugging difficulty Stack traces across plugin boundaries are harder to follow
No type checking at load Lookup returns any — runtime panics if types don't match

Production Alternatives

Alternative 1: hashicorp/go-plugin (RPC/gRPC)

The most widely used plugin system in the Go ecosystem. Used by Terraform, Vault, Packer, and Nomad. Each plugin runs as a separate process communicating over RPC or gRPC.

// Shared interface definition (in a shared package)
package shared

type KVStore interface {
    Get(key string) (string, error)
    Put(key, value string) error
}
// Plugin implementation (separate binary)
package main

import (
    "github.com/hashicorp/go-plugin"
    "myapp/shared"
)

type RedisKV struct{}

func (r *RedisKV) Get(key string) (string, error) {
    return "value-from-redis", nil
}

func (r *RedisKV) Put(key, value string) error {
    return nil
}

func main() {
    plugin.Serve(&plugin.ServeConfig{
        HandshakeConfig: shared.Handshake,
        Plugins: map[string]plugin.Plugin{
            "kv": &shared.KVPlugin{Impl: &RedisKV{}},
        },
        GRPCServer: plugin.DefaultGRPCServer,
    })
}
// Host application
package main

import (
    "fmt"
    "os/exec"

    "github.com/hashicorp/go-plugin"
    "myapp/shared"
)

func main() {
    client := plugin.NewClient(&plugin.ClientConfig{
        HandshakeConfig: shared.Handshake,
        Plugins:         shared.PluginMap,
        Cmd:             exec.Command("./plugins/redis-kv"),
        AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
    })
    defer client.Kill()

    rpcClient, _ := client.Client()
    raw, _ := rpcClient.Dispense("kv")

    kv := raw.(shared.KVStore)
    kv.Put("hello", "world")
    val, _ := kv.Get("hello")
    fmt.Println(val) // "value-from-redis"
}

Advantages: Cross-platform, crash isolation (plugin crash doesn't kill host), plugins can be written in any language (via gRPC), independent deployment, no version coupling.

Alternative 2: gRPC-Based Plugins

For polyglot environments, define plugins as gRPC services:

// plugin.proto
syntax = "proto3";

service PluginService {
    rpc Execute(Request) returns (Response);
    rpc HealthCheck(Empty) returns (HealthResponse);
}

message Request {
    string action = 1;
    bytes  payload = 2;
}

message Response {
    bytes  result = 1;
    string error  = 2;
}
// Host discovers and communicates with plugins over gRPC
func loadPlugins(ctx context.Context, pluginAddrs []string) []PluginServiceClient {
    var clients []PluginServiceClient
    for _, addr := range pluginAddrs {
        conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
            log.Printf("failed to connect to plugin %s: %v", addr, err)
            continue
        }
        clients = append(clients, NewPluginServiceClient(conn))
    }
    return clients
}

Advantages: Language-agnostic, well-defined API contracts via protobuf, standard tooling for health checks and load balancing, works over network boundaries.

Alternative 3: Interface-Based Plugin System (Build-Time)

The simplest approach — plugins register themselves at compile time via init() and a shared registry:

// plugin/registry.go
package plugin

type Processor interface {
    Name() string
    Process(data []byte) ([]byte, error)
}

var registry = make(map[string]Processor)

func Register(p Processor) {
    registry[p.Name()] = p
}

func Get(name string) (Processor, bool) {
    p, ok := registry[name]
    return p, ok
}

func All() map[string]Processor {
    return registry
}
// plugins/compress/compress.go
package compress

import "myapp/plugin"

type GzipProcessor struct{}

func (g GzipProcessor) Name() string { return "gzip" }

func (g GzipProcessor) Process(data []byte) ([]byte, error) {
    return gzipCompress(data)
}

func init() {
    plugin.Register(GzipProcessor{})
}
// main.go
package main

import (
    "myapp/plugin"
    _ "myapp/plugins/compress"  // blank import triggers init()
    _ "myapp/plugins/encrypt"   // add more plugins here
)

func main() {
    p, ok := plugin.Get("gzip")
    if !ok {
        log.Fatal("plugin not found")
    }
    result, err := p.Process(data)
    // ...
}

Advantages: No runtime loading, full type safety, works on all platforms, no version coupling, simple debugging, standard Go tooling. Disadvantage: Plugins must be compiled into the binary — can't add them at runtime.

Alternative 4: Script-Based Plugins (Embedded Interpreters)

Embed a scripting language for runtime-extensible plugins:

// Using github.com/traefik/yaegi (Go interpreter)
import "github.com/traefik/yaegi/interp"

func runGoPlugin(src string) error {
    i := interp.New(interp.Options{})
    i.Use(stdlib.Symbols)

    _, err := i.Eval(src)
    return err
}

Other options include embedding Lua (via gopher-lua), JavaScript (via goja), or Wasm (via wazero).

Comparison of Plugin Approaches

Approach Runtime Loading Cross-Platform Type Safety Crash Isolation Complexity
plugin package Yes No (Linux) No No Low
hashicorp/go-plugin Yes Yes Via interface Yes Medium
gRPC plugins Yes Yes Via protobuf Yes Medium–High
Interface registry (build-time) No Yes Yes No Low
Embedded interpreter Yes Yes No Partial Medium

Quick Reference

Concept Detail
Build a plugin go build -buildmode=plugin -o x.so ./x/
Load a plugin p, err := plugin.Open("x.so")
Lookup symbol sym, err := p.Lookup("ExportedName")
Type assert fn := sym.(func(string) string)
Platform support Linux only (macOS experimental)
hashicorp/go-plugin Process-based, RPC/gRPC communication
Interface registry Compile-time, init() + blank imports
gRPC plugins Language-agnostic, protobuf contracts

Best Practices

  1. Default to interface-based registration — for most Go projects, compile-time plugin registration via interfaces and init() is the simplest, safest, and most portable approach.
  2. Use hashicorp/go-plugin for process isolation — when plugins come from untrusted sources or when crash isolation is required (e.g., Terraform providers).
  3. Avoid the plugin package in new projects — the platform restrictions, version coupling, and operational complexity rarely justify its use.
  4. Define plugin contracts as interfaces — regardless of the mechanism, define the plugin API as a Go interface. This gives you type safety and makes swapping implementations trivial.
  5. Version your plugin API — include a version in the handshake or interface to handle backward/forward compatibility.
  6. Health checks for process-based plugins — if plugins are separate processes, implement health checks and restart logic. hashicorp/go-plugin does this automatically.

Common Pitfalls

Go version mismatch with plugin package

# Plugin compiled with Go 1.21
go1.21 build -buildmode=plugin -o myplugin.so

# Host compiled with Go 1.22
go1.22 build -o myapp .
# plugin.Open fails: "plugin was built with a different version of package runtime"
The plugin package requires identical Go compiler versions for host and plugin. This makes independent deployment of plugins extremely fragile.

Dependency version mismatch

If the host uses google.golang.org/grpc v1.60.0 and the plugin uses v1.58.0, plugin.Open will fail. Every shared dependency must be at the exact same version — making dependency management a nightmare.

No plugin unloading

p, _ := plugin.Open("myplugin.so")
// No p.Close() or p.Unload() — the plugin stays in memory forever
Loaded plugins cannot be unloaded. If you need to reload plugins, use process-based approaches (hashicorp/go-plugin) where you can kill and restart the plugin process.

Symbol lookup is stringly-typed

sym, _ := p.Lookup("Proccess")  // typo: "Proccess" instead of "Process"
// sym is nil, err says symbol not found — no compile-time checking
Lookup uses string names — typos are caught at runtime, not compile time. Interface-based registration avoids this entirely.

Performance Considerations

  • plugin.Open is slow: Loading a .so file involves dynamic linking — expect 10–100ms depending on plugin size. Do it once at startup, not per-request.
  • Function calls are fast: Once loaded, calling a function through a type-asserted symbol is as fast as any Go function call — no overhead beyond the initial lookup.
  • hashicorp/go-plugin overhead: Each call crosses a process boundary via RPC/gRPC. Expect ~50–500µs per call depending on payload size. Not suitable for tight loops with thousands of calls per second, but perfectly fine for coarse-grained operations.
  • Interface registry has zero overhead: Build-time registration compiles plugins directly into the binary — function calls are direct, no indirection beyond the interface dispatch.
  • Memory: The plugin package loads the entire .so into memory and never releases it. Process-based plugins use separate process memory, which can be reclaimed when the plugin exits.

Interview Tips

Interview Tip

"How would you design a plugin system in Go?" Start with the requirements: Does it need runtime loading? Cross-platform? Crash isolation? For most cases, define a Go interface for the plugin contract and use build-time registration with init() and blank imports. If runtime loading is needed, use hashicorp/go-plugin (separate process, gRPC communication). Avoid the standard plugin package — it's Linux-only and has severe version coupling.

Interview Tip

"Why don't most Go projects use the plugin package?" Four dealbreakers: (1) Linux-only. (2) Requires identical Go compiler version for host and plugin. (3) Requires identical versions of all shared dependencies. (4) No unloading — memory grows forever. hashicorp/go-plugin solves all of these by running plugins as separate processes communicating over gRPC.

Interview Tip

"How does hashicorp/go-plugin work?" The host spawns the plugin as a child process. They negotiate a handshake (version, protocol), then communicate over gRPC (or net/rpc). The host gets a client stub that implements the plugin interface. If the plugin crashes, the host detects it and can restart. This gives crash isolation, cross-platform support, and even cross-language plugins (anything that speaks gRPC).

Interview Tip

"What's the simplest extensible architecture in Go?" Define an interface, create a package-level registry map, have plugins call Register() in their init() function, and use blank imports (_ "myapp/plugins/foo") in main to pull them in. It's type-safe, zero overhead, works everywhere, and is the pattern used by database/sql drivers, image format decoders, and many Go libraries.

Key Takeaways

  • Go's plugin package provides dynamic loading via plugin.Open and Lookup, but is Linux-only and requires exact version alignment.
  • Most production Go systems avoid the plugin package — the constraints are too severe for real-world deployment.
  • hashicorp/go-plugin is the gold standard for runtime plugin loading — process-isolated, cross-platform, gRPC-based, used by Terraform/Vault.
  • Interface-based registration (compile-time) is the simplest and most common pattern — zero overhead, fully type-safe, works everywhere.
  • The database/sql driver model (interface + init() registration + blank imports) is Go's canonical plugin pattern.
  • For polyglot environments, gRPC-based plugins give language-agnostic contracts with strong tooling.
  • Always define plugin contracts as Go interfaces — this decouples the mechanism from the API.